Nimbin[12]?Games / Entropie / entropie/src/game/game.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 10 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/game/game.c 26.5 KB · 539 lines raw
/*
 * The game: one frame of camera, time, talk and drawing.
 * What the keys and clicks do lives in game_actions.c.
 */

#include "game/game_internal.h"
#include "world/clothes.h"
#include "world/car.h"
#include "people/condition.h"

#define START_HOUR   13.0f
#define ARRIVE_BY_CAR   1   /* (testing) 0: start standing in the yard, no car */
#define START_AT_COURSE 0   /* (testing) 1: start at the obstacle course at the chicken farm; 0: in the yard (the course is there either way) */
#define TIME_SCALE    3.0f  /* game seconds per real second */
#define CAMERA_FOV   70.0f
#define WORN_OUT       0.12f /* energy below this: "worn out" ... */
#define WORN_OUT_UNTIL 0.3f  /* ... until it is back above this */
#define SHADOW_FOCUS_AHEAD 15.0f /* center shadows a bit ahead of the camera */
#define GRAB_LOOK_LIMIT    75.0f /* degrees you can look away from a grabbed object */
#define THIRD_PERSON_DISTANCE 3.2f
#define THIRD_PERSON_SHOULDER 0.45f
#define SLEEP_FAST_FORWARD 60.0f /* game time runs this much faster while you sleep holding Space */

/* The camera never goes into a wall: what's between the head and where it
   would be pulls it in (at once), and it eases back out when there's room. */
#define CAMERA_CLEAR    0.25f /* m kept off what it would go into */
#define CAMERA_OUT_RATE 2.5f  /* m/s back out */
#define CAMERA_FAN      0.15f /* m: rays round the middle one (the view's corners) */

static bool cameraSkips(const Object *o, const void *ctx) {
    (void)ctx;
    if (o->is_character || o->is_held || o->type == OBJ_DOG) return true;
    if (!o->is_static) return true; /* (loose things don't shove the view about) */
    return Object_reach(o) < 0.35f;  /* (nor poles and posts) */
}

static float clearDistance(const World *world, Vec3 from, Vec3 dir, float want, Vec3 right, Vec3 up) {
    float best = want;
    const Vec3 fan[5] = { vec3_zero(), vec3_scale(right, CAMERA_FAN), vec3_scale(right, -CAMERA_FAN),
                          vec3_scale(up, CAMERA_FAN), vec3_scale(up, -CAMERA_FAN) };
    for (int k = 0; k < 5; k++) {
        float t = World_rayCast(world, vec3_add(from, fan[k]), dir, want + CAMERA_CLEAR, cameraSkips, NULL, NULL, NULL);
        if (t >= 0.0f) best = fminf(best, t - CAMERA_CLEAR);
    }
    /* (the ground along the way) */
    for (int k = 1; k <= 4; k++) {
        float d = best * (float)k / 4.0f;
        Vec3 at = vec3_add(from, vec3_scale(dir, d));
        if (at.y < World_groundHeight(world, at.x, at.z, from.y) + CAMERA_CLEAR) { best = fmaxf(0.0f, d - CAMERA_CLEAR); break; }
    }
    return fmaxf(best, 0.0f);
}

static void followPlayer(Game *game, float dt) {
    Camera *cam = &game->camera;
    const Object *p = game->player;
    Vec3 eye = vec3_add(p->position, vec3(0, PLAYER_EYE_HEIGHT, 0));
    if (p->riding) { cam->position = Car_eye(p->riding, p->ride_seat); return; } /* (in the car: from the seat) */
    if (Body_isDown(&p->body)) {
        /* On the ground: see from the head, wherever it is */
        Vec3 facing = Object_facing(p);
        Vec3 head = Figure_toWorld(p->position, facing, p->skeleton.joints[JOINT_HEAD]);
        Vec3 face = Figure_toWorld(vec3_zero(), facing, p->skeleton.head_forward);
        eye = vec3_add(head, vec3_add(vec3_scale(face, 0.1f), vec3(0.0f, 0.05f, 0.0f)));
    }
    if (game->third_person) {
        /* Over the right shoulder, behind the player: the boom pulled in by walls */
        Vec3 want = vec3_add(vec3_add(vec3_scale(cam->front, -THIRD_PERSON_DISTANCE), vec3_scale(cam->right, THIRD_PERSON_SHOULDER)),
                             vec3(0.0f, 0.25f, 0.0f));
        float len = vec3_length(want);
        Vec3 dir = vec3_scale(want, 1.0f / len);
        Vec3 up = vec3_normalize(vec3_cross(cam->right, dir));
        float room = clearDistance(&game->world, eye, dir, len, cam->right, up);
        if (game->camera_reach <= 0.0f || room < game->camera_reach) game->camera_reach = room;
        else game->camera_reach = fminf(room, game->camera_reach + CAMERA_OUT_RATE * dt);
        eye = vec3_add(eye, vec3_scale(dir, game->camera_reach));
    }
    cam->position = eye;
}

/* Holding on to something (F): looking left or right turns it that way (you and
   your helpers walk round with it), as fast as you're able to. Ctrl: look
   around freely; let go of Ctrl and the view swings back to it. */
#define GRAB_TURN_FREE   12.0f  /* degrees you can look aside without turning it */
#define GRAB_LOOK_BACK  300.0f  /* degrees/s the view swings back after looking around */

static void grabLook(Game *game, const Input *input, float dt) {
    static bool looked_away = false;
    if (!Hands_isGrabbing(game->player)) { looked_away = false; return; }
    Vec3 h = game->player->hands.grab_heading;
    float heading = atan2f(h.z, h.x) * 180.0f / PI_F;
    float away = fmodf(game->camera.yaw - heading + 540.0f, 360.0f) - 180.0f;
    if (input->free_look) { looked_away = true; return; }
    if (looked_away) { /* back to it first, no turning meanwhile */
        float back = GRAB_LOOK_BACK * dt;
        if (fabsf(away) <= GRAB_TURN_FREE + back) looked_away = false;
        away -= clampf(away, -back, back);
    } else if (fabsf(away) > GRAB_TURN_FREE) {
        float want = (away - (away > 0.0f ? GRAB_TURN_FREE : -GRAB_TURN_FREE)) * PI_F / 180.0f;
        float turned = Hands_turnGrabbed(game->player, game->world.objects, game->world.count, want, dt);
        heading += turned * 180.0f / PI_F;
        away -= turned * 180.0f / PI_F;
    }
    game->camera.yaw = heading + clampf(away, -GRAB_LOOK_LIMIT, GRAB_LOOK_LIMIT);
    Camera_rotate(&game->camera, 0.0f, 0.0f);
}

/* Drunk: the view sways and tilts, a stumble jolts it. */
static void swayView(Game *game) {
    const Object *p = game->player;
    Camera *cam = &game->camera;
    float d = fminf(p->drunk, 1.0f);
    float t = p->body.clock;
    float jolt = p->body.stumble > 0.0f ? sinf(p->body.stumble * 9.0f) * p->body.stumble : 0.0f;
    float hit = p->work_state.impact / 0.18f; /* the tool bites into the wood */
    cam->sway_yaw   = d * 4.0f * sinf(t * 0.55f) + jolt * 3.0f;
    cam->sway_pitch = d * 2.5f * sinf(t * 0.83f + 1.0f) - fabsf(jolt) * 4.0f - 1.2f * hit * hit;
    cam->roll       = d * 6.0f * sinf(t * 0.41f + 2.0f) + jolt * 5.0f + 0.8f * hit * hit;
}

const char *Game_status(const Game *game) {
    const Body *b = &game->player->body;
    if (b->asleep && b->pose == POSE_DOWN)
        return game->fast_sleep ? "z z z   >>>" : "z z z   (hold Space to sleep faster)";
    if (b->vomit > 0.0f) return "*bleargh*";
    if (b->pee > 0.0f) return game->player->needs.bladder >= 1.0f ? "Oops... couldn't hold it"
                            : b->pee_power > 0.5f ? "*PSSSHHHH*" : "*psssh*  (Shift: further - aim with the mouse)";
    if (b->burp > 0.0f) return "*burp*";
    if (game->message_time > 0.0f) return game->message;
    return NULL;
}

/* Word for how bad a need is: nothing, then mild, then strong. */
static const char *needWord(float value, const char *mild, const char *strong) {
    if (value >= 0.85f) return strong;
    if (value >= 0.6f) return mild;
    return NULL;
}

void Game_needsText(const Game *game, char *out, size_t size) {
    const Needs *n = &game->player->needs;
    const char *words[] = {
        game->worn_out ? "worn out" : NULL,
        needWord(n->hunger, NULL, "starving"),
        needWord(n->thirst, NULL, "parched"),
        needWord(n->sleepiness, NULL, "exhausted"),
        needWord(n->bladder, NULL, "bursting"),
        game->player->smoker > 0.0f && game->player->craving > 0.85f && !Body_isSmoking(&game->player->body) ? "dying for a smoke" : NULL,
    };
    int k = 0;
    if (size > 0) out[0] = '\0';
    for (unsigned i = 0; i < sizeof(words) / sizeof(words[0]); i++)
        if (words[i] && k >= 0 && (size_t)k < size) k += snprintf(out + k, size - (size_t)k, "%s%s", k ? "   " : "", words[i]);
}

/* You, working hard in the midday sun: off with the shirt after a while (a top
   if you're a woman), on again when you've cooled down (as the others do). */
static void sunShirt(Game *game, float dt) {
    Object *p = game->player;
    float hour = game->daycycle.seconds / 3600.0f;
    bool sunny = hour >= 11.0f && hour < 17.5f, working = p->work_state.since < 3.0f;
    float warm = sunny && working ? 1.0f + 0.5f * (1.0f - p->needs.stamina) : sunny ? -0.5f : -3.0f;
    p->ai.sun_heat = clampf(p->ai.sun_heat + warm * dt, 0.0f, 135.0f);
    if (p->look.top == TOP_SHIRT && p->ai.sun_heat >= 90.0f) {
        p->look.top = p->female ? TOP_CROP : TOP_BARE;
        say(game, "Hot work - you take your shirt off");
    } else if ((p->look.top == TOP_BARE || p->look.top == TOP_CROP) && p->ai.sun_heat < 40.0f) {
        p->look.top = TOP_SHIRT;
        say(game, "You put your shirt back on");
    }
}

int Game_stats(const Game *game, StatBar *bars, int max, const char **title, char *about, size_t size) {
    const Object *p = game->player;
    const Needs *n = &p->needs;
    const StatBar body[] = {
        { "Energy",  n->stamina,              false },
        { "Hunger",  n->hunger,               true },
        { "Thirst",  n->thirst,               true },
        { "Bladder", n->bladder,              true },
        { "Sleepy",  n->sleepiness,           true },
        { "Worn (days)", p->worn,             true },
        { "Drunk",   fminf(p->drunk, 1.0f),   true },
        { "Dirty",   p->dirt,                 true },
        { "Wet",     p->look.wet,             true },
        { "Sporty",  p->fitness,              false },
    };
    const StatBar skills[] = { /* grow with practice */
        { "Firewood", p->skill[ORDER_FIREWOOD], false },
        { "Felling",  p->skill[ORDER_TREES],    false },
        { "Tidying",  p->skill[ORDER_TRASH],    false },
        { "Fire",     p->skill[ORDER_FIRE],     false },
        { "Ladder (one hand)", p->ladder_skill, false },
    };
    const bool skill_page = game->stats_page == 1;
    const StatBar *all = skill_page ? skills : body;
    const int total = skill_page ? (int)(sizeof(skills) / sizeof(skills[0])) : (int)(sizeof(body) / sizeof(body[0]));
    int count = 0;
    for (int i = 0; i < total && count < max; i++) bars[count++] = all[i];
    *title = skill_page ? "Skills" : "Body";
    if (skill_page) {
        char lighter[24] = "no lighter";
        if (p->lighter) snprintf(lighter, sizeof lighter, p->lighter_uses > 0 ? "lighter (%d)" : "empty lighter", p->lighter_uses);
        snprintf(about, size, "Pockets: %d cigarettes, tobacco for %d, %d papers, %d filters, %s",
                 p->cigarettes, p->tobacco, p->papers, p->filters, lighter);
    } else {
        float jump = Object_jumpSpeed(p);
        snprintf(about, size, "Sprint %.1f m/s   Jump %d cm   %d kg   %s-handed",
                 Object_topSpeed(p), (int)(jump * jump / (2.0f * 9.81f) * 100.0f + 0.5f), (int)(p->weight + 0.5f),
                 p->main_hand == HAND_LEFT ? "left" : "right");
    }
    return count;
}

bool Game_init(Game *game) { return Game_initWorld(game, true); }

bool Game_initWorld(Game *game, bool arrive) {
    game->dark = 0.0f;
    game->show_keys = false;
    game->fast_sleep = false;
    game->message = NULL;
    game->message_time = 0.0f;
    game->third_person = true;
    game->camera_reach = 0.0f;
    game->holding = false;
    game->click_action = 0;
    game->info[0] = '\0';
    game->info_about = NULL;
    memset(&game->talk, 0, sizeof(game->talk));
    World_init(&game->world);
    DayCycle_init(&game->daycycle, START_HOUR, TIME_SCALE);
    Camera_init(&game->camera, vec3(0.0f, 1.8f, 5.0f), -90.0f, 0.0f, CAMERA_FOV);

    game->player = Scene_load(&game->world);
    if (!game->player || !World_buildTerrain(&game->world) || !Site_finish(&game->world) || !Scene_finish(&game->world)) {
        SDL_Log("Scene loading failed");
        Game_shutdown(game);
        return false;
    }
    /* Start looking at your building (or down the obstacle course) */
    Vec3 to = vec3_sub(Site_landmark(), game->player->position);
    float look = atan2f(to.z, to.x);
    if (!Course_build(&game->world, START_AT_COURSE ? game->player : NULL, &look)) {
        Game_shutdown(game);
        return false;
    }
    Camera_init(&game->camera, game->camera.position, look * 180.0f / PI_F, 0.0f, CAMERA_FOV);
    { /* two umbrellas: by the main building's door and in your garages' kitchen (last: the scene's own come first) */
        Vec3 at[2] = { vec3_add(Site_houseDoor(), vec3(0.9f, 0.0f, 0.4f)), vec3_add(Site_drinksSpot(), vec3(-0.8f, 0.0f, 0.6f)) };
        for (int k = 0; k < 2; k++) {
            at[k].y = World_groundHeight(&game->world, at[k].x, at[k].z, at[k].y + 3.0f) + 0.06f;
            Object *u = Object_create(OBJ_UMBRELLA, PALETTE_WARM, at[k], vec3_zero(), (float)k);
            if (!u) continue;
            Object_setRotation(u, vec3(PI_F / 2.0f, 0.4f + 1.3f * (float)k, 0.0f)); /* (lying there) */
            if (!World_add(&game->world, u)) continue;
        }
    }
    game->marker = Shapes_marker(1.8f, 0.8f);
    game->quest_mesh = Shapes_questMarker(0.7f, vec3(1.0f, 0.82f, 0.15f));
    for (int i = 0; i < game->world.count; i++) { /* (what the scene made: a save finds it again by this) */
        Object *o = game->world.objects[i];
        o->scene_id = i + 1;
        o->spawn = o->position;
        o->spawn_type = o->type;
    }
    memset(&game->arrival, 0, sizeof(game->arrival));
    if (arrive && ARRIVE_BY_CAR && !START_AT_COURSE) game_meetingStart(game); /* (the story starts in Berlin; then the drive in) */
    followPlayer(game, 0.0f);
    return true;
}

void Game_shutdown(Game *game) {
    Meadow_destroy(&game->meadow);
    World_destroy(&game->world);
    Mesh_destroy(&game->marker);
    Mesh_destroy(&game->quest_mesh);
    Dog_shutdown();
    game->player = NULL;
}

/* Putting a ladder up: you stand still, both hands on it, where it passes your chest. */
static void puttingUp(Game *game) {
    Object *p = game->player, *l = game->putting_up;
    if (!l) return;
    if (l->placing <= 0.0f) { game->putting_up = NULL; return; }
    p->movement = p->move_local = vec3_zero();
    Vec3 axis = l->colliders[0].axes[1];
    float len = 2.0f * l->colliders[0].half_extents.y;
    Vec3 chest = vec3_add(p->position, vec3(0.0f, 1.15f * p->look.height / 1.75f, 0.0f));
    float along = clampf(vec3_dot(vec3_sub(chest, l->position), axis), 0.3f, fmaxf(0.3f, len - 0.2f));
    Vec3 grip = vec3_add(l->position, vec3_scale(axis, along));
    Vec3 across = l->colliders[0].axes[0];
    if (vec3_dot(across, vec3_cross(Object_facing(p), vec3(0.0f, 1.0f, 0.0f))) < 0.0f) across = vec3_scale(across, -1.0f);
    Hands_holdEdge(p, grip, across, 0.0f);
}

/* Rain: the light duller, the sky grey, no sharp shadows; everything wet. */
static void rainLight(Game *game) {
    Lighting *l = &game->daycycle.light;
    float r = game->world.rain;
    const Vec3 grey = { 0.56f, 0.58f, 0.62f };
    float bright = 0.35f + 0.65f * l->day_factor;
    l->light_color  = vec3_scale(l->light_color, 1.0f - 0.45f * r);
    l->sky_top      = vec3_lerp(l->sky_top, vec3_scale(grey, 0.8f * bright), fminf(1.0f, 1.3f * r));
    l->sky_horizon  = vec3_lerp(l->sky_horizon, vec3_scale(grey, bright), fminf(1.0f, 1.2f * r));
    l->shadow_strength *= 1.0f - 0.85f * r;
    float f = game->world.flash; /* (lightning: everything lit up white for a moment) */
    l->light_color = vec3_add(l->light_color, vec3_scale(vec3(1.4f, 1.45f, 1.6f), f));
    l->sky_top     = vec3_lerp(l->sky_top, vec3(0.9f, 0.92f, 1.0f), 0.7f * f);
    l->sky_horizon = vec3_lerp(l->sky_horizon, vec3(0.95f, 0.95f, 1.0f), 0.7f * f);
    l->wet = game->world.wet;
    /* clouding over: before the rain comes (and staying a while after), with it fully */
    float coming = clampf(game->world.rain_goal, 0.0f, 1.0f);
    game->overcast += (fminf(1.0f, fmaxf(1.6f * r, 0.7f * coming)) - game->overcast) * fminf(1.0f, game->world.game_dt / 600.0f);
    l->overcast = fmaxf(game->overcast, fminf(1.0f, 1.6f * r));
}

/* Worn out with something heavy in your arms (a crate): down it goes for a moment. */
static void tooHeavy(Game *game) {
    Object *p = game->player;
    if (p->needs.stamina > 0.06f || Condition_load(p) < 0.15f) return;
    for (int h = 0; h < 2; h++) if (p->hands.state[h] == HAND_HOLDING) { Hands_drop(p, h); break; }
    say(game, "Too heavy - you have to put it down for a moment (or get somebody to take the other side: F, then E)");
}

void Game_update(Game *game, const Input *input, float dt, float aspect) {
    /* Asleep: hold Space and the hours fly by */
    const Body *pb = &game->player->body;
    game->fast_sleep = pb->asleep && pb->pose == POSE_DOWN && input->jump;
    const float speed = game->fast_sleep ? SLEEP_FAST_FORWARD : 1.0f;
    DayCycle_update(&game->daycycle, dt * speed);
    rainLight(game);

    {   /* worn out: shown until a bit rested again */
        float stamina = game->player->needs.stamina;
        if (stamina < WORN_OUT) game->worn_out = true;
        else if (stamina > WORN_OUT_UNTIL) game->worn_out = false;
    }
    Camera_rotate(&game->camera, input->look_yaw, input->look_pitch);
    game->dt = dt;
    game_handleActions(game, input);
    game_talk(game, input, dt);
    grabLook(game, input, dt);
    Player_update(game->player, input, &game->camera, dt);
    puttingUp(game);
    game_climbLadder(game, input, dt);
    game_climb(game, input, dt);
    game_touch(game, dt); /* (after your move: holding on to something keeps you near it) */
    game->world.eye = game->camera.position;
    if (game->world.rain > 0.3f && !game->rain_told) {
        game->rain_told = true;
        say(game, "It's starting to rain - under a roof (the house, your garages) or into your tent");
    } else if (game->world.rain < 0.05f) game->rain_told = false;
    if (game->world.thunder) { /* (the thunder: said, and somebody near reacts) */
        static const char *const ROLL[] = { "*KRRRAKK* - thunder, close by", "*rumble* - thunder rolls over the farm", "*BOOM* - that one was close" };
        say(game, ROLL[(int)(game->daycycle.seconds) % 3]);
        for (int i = 0; i < game->world.count; i++) {
            Object *o = game->world.objects[i];
            if (o->type != OBJ_HUMAN || o->body.asleep || o->indoors > 0.0f || o->ai.speech_time > 0.0f || ((int)(game->daycycle.seconds) + i) % 4) continue;
            Ai_say(o, (i % 2) ? "Whoa! That was close." : "Did you see that one?");
            break;
        }
    }
    game->blow_told = fmaxf(0.0f, game->blow_told - dt);
    if (game->world.tent_blowing && game->blow_told <= 0.0f) { /* (a tent not pegged down, the wind pushing it about) */
        const Object *t = game->world.tent_blowing, *o = t->owner;
        static char line[96];
        snprintf(line, sizeof(line), "%s%s tent is blowing about in the wind - peg it down!", !o ? "A" : o == game->player ? "Your" : o->name, o && o != game->player ? "'s" : "");
        say(game, line);
        game->blow_told = 60.0f;
    }
    if (game->player->look.wet > 0.6f && !game->wet_told) {
        game->wet_told = true;
        say(game, "You're wet through - by a fire you dry quickest");
    } else if (game->player->look.wet < 0.2f) game->wet_told = false;
    game_arrival(game, dt);
    game_meeting(game, input, dt);
    if (!game->meeting.kind && !Game_inBerlin(game->player)) game_story(game, game->world.game_dt); /* (the hours on the ground: on the ground) */
    game_shop(game, dt);
    game->quest_t += dt;
    if (!game->meeting.kind) game_why(game, dt);
    game_festival(game, dt);
    game_cleanup(game, dt);
    if (!game->meeting.kind) { game_bigTree(game, dt); game_injury(game, dt); }
    Ai_setSettled(game->arrival.task == TASK_NONE);
    tooHeavy(game);
    bool warm = game->player->look.top == TOP_JACKET;
    World_update(&game->world, dt, dt * game->daycycle.time_scale * speed);
    Object *you = game->player;
    if (!warm && you->look.top == TOP_JACKET) {
        say(game, "Brr, it's getting cold - you pull your pullover on (B: off again)");
    } else if (you->chill > 0.6f && you->pullover_down && you->look.top != TOP_JACKET && !you->jacket_told) {
        static char line[96];
        you->jacket_told = true;
        snprintf(line, sizeof(line), "Brr, it's getting cold - your pullover is %s", Clothes_where(you));
        say(game, line);
    }
    if (game->player->body.stepped_off) {
        game->player->body.stepped_off = false;
        say(game, "Whoa - you lose your balance and step off");
    }
    for (int i = 0; i < game->world.count; i++) { /* a ladder that fell against something near you */
        Object *l = game->world.objects[i];
        if (l->type != OBJ_LADDER || !l->just_caught) continue;
        l->just_caught = false;
        if (vec3_length(vec3_sub(l->position, game->player->position)) < 15.0f)
            say(game, l->lean_top > 0.0f ? "It falls against it and rests there - not too steady (G: somebody holds it)"
                                         : "It falls against it and stays leaning there - not too steady");
    }
    if (game->player->carry_blocked) { /* what you carry hit something: the view doesn't turn on either */
        game->camera.yaw = (game->player->rotation.y - PI_F / 2.0f) * 180.0f / PI_F;
        Camera_rotate(&game->camera, 0.0f, 0.0f);
    }
    Object *grilled = Camp_update(game->world.objects, game->world.count, dt, dt * game->daycycle.time_scale * speed);
    if (grilled && (grilled->on_grill || grilled == game->player->hands.item[0] || grilled == game->player->hands.item[1]))
        tell(game, grilled);
    game->bag_timer = fmaxf(0.0f, game->bag_timer - dt);
    game_holdHose(game);
    if (camp_doused) {
        camp_doused = false;
        say(game, "Pssssshhh... the fire is out");
    }
    Ai_setHour(game->daycycle.seconds / 3600.0f);
    game->world.hour = game->daycycle.seconds / 3600.0f;
    sunShirt(game, dt);
    if (game->player->on_fire > 0.0f && (game->fire_nag -= dt) <= 0.0f) {
        say(game, "You're on fire!");
        game->fire_nag = 4.0f;
    }

    game->message_time = fmaxf(0.0f, game->message_time - dt);

    /* Your own nose */
    float stink = Object_stinkAt(game->world.objects, game->world.count, game->player->position);
    game->sniff_time = fmaxf(0.0f, game->sniff_time - dt);
    if (stink > 0.15f) {
        game->player->body.disgust = fmaxf(game->player->body.disgust, fminf(1.0f, stink * 1.5f));
        if (game->sniff_time <= 0.0f && game->message_time <= 0.0f) {
            say(game, stink > 0.6f ? "Ugh, it really stinks here" : "Something smells...");
            game->sniff_time = 20.0f;
        }
    }

    /* Falling asleep: the world fades to dark, and back when waking up */
    const Body *b = &game->player->body;
    float dark_goal = b->asleep && b->pose != POSE_UP ? 1.0f : 0.0f;
    game->dark += (dark_goal - game->dark) * fminf(1.0f, 0.8f * dt);

    followPlayer(game, dt);
    swayView(game);
    Camera_updateMatrices(&game->camera, aspect);
}

/* World point -> screen pixels (y up from the bottom); false if behind the camera. */
static bool toScreen(const Camera *cam, Vec3 p, int w, int h, float *x, float *y) {
    Mat4 m = mat4_mul(cam->projection, cam->view);
    float cx = m.m[0] * p.x + m.m[4] * p.y + m.m[8]  * p.z + m.m[12];
    float cy = m.m[1] * p.x + m.m[5] * p.y + m.m[9]  * p.z + m.m[13];
    float cw = m.m[3] * p.x + m.m[7] * p.y + m.m[11] * p.z + m.m[15];
    if (cw < 0.1f) return false;
    *x = (cx / cw * 0.5f + 0.5f) * (float)w;
    *y = (cy / cw * 0.5f + 0.5f) * (float)h;
    return *x > -200.0f && *x < (float)w + 200.0f && *y > -50.0f && *y < (float)h + 50.0f;
}

static Vec3 aboveHead(const Object *o) {
    Vec3 head = Figure_toWorld(o->position, Object_facing(o), o->skeleton.joints[JOINT_HEAD]);
    return vec3_add(head, vec3(0.0f, 0.35f, 0.0f));
}

int Game_bubbles(const Game *game, Bubble *out, int max, int screen_w, int screen_h) {
    int n = 0;
    for (int i = 0; i < game->world.count && n < max; i++) {
        const Object *o = game->world.objects[i];
        if ((o->type != OBJ_HUMAN && o->type != OBJ_DOG) || o->ai.speech_time <= 0.0f) continue;
        Bubble *b = &out[n];
        if (!toScreen(&game->camera, aboveHead(o), screen_w, screen_h, &b->x, &b->y)) continue;
        snprintf(b->text, sizeof(b->text), "%s: %s", o->name, o->ai.speech);
        n++;
    }
    /* The player's own answer: above the head, or low in the middle in first person */
    const Talk *t = &game->talk;
    if (t->player_time > 0.0f && n < max) {
        Bubble *b = &out[n];
        bool shown = game->third_person &&
                     toScreen(&game->camera, aboveHead(game->player), screen_w, screen_h, &b->x, &b->y);
        if (!shown) {
            b->x = (float)screen_w * 0.5f;
            b->y = (float)screen_h * 0.2f;
        }
        snprintf(b->text, sizeof(b->text), "You: %s", t->player_line);
        n++;
    }
    return n;
}


void Game_renderAir(Game *game, Renderer *renderer) {
    Renderer_beginAirPass(renderer);
    World_drawAir(&game->world, renderer);
}

void Game_render(Game *game, Renderer *renderer) {
    /* A campfire lights its surroundings, the more the darker it is */
    Lighting lit = game->daycycle.light;
    float fire = Camp_firelight(game->world.objects, game->world.count, &lit.fire_position);
    lit.fire_strength = fire * (0.25f + 1.6f * (1.0f - lit.day_factor));
    const Lighting *light = &lit;

    const Camera *cam = &game->camera;
    Vec3 ahead = vec3_normalize(vec3(cam->front.x, 0.0f, cam->front.z));
    Vec3 focus = vec3_add(cam->position, vec3_scale(ahead, SHADOW_FOCUS_AHEAD));

    Renderer_beginShadowPass(renderer, light, focus);
    World_drawShadows(&game->world, renderer);
    Renderer_endShadowPass(renderer);

    renderer->drunk = fminf(game->player->drunk, 1.0f);
    renderer->dark  = fmaxf(game->dark * (game->third_person ? 0.55f : 0.92f), game->meeting.fade); /* eyes shut; going to Berlin and back */
    Renderer_beginScenePass(renderer, &game->camera, light);
    World_draw(&game->world, renderer, game->third_person && game->camera_reach > 0.6f); /* (pulled right in: from the head) */
    Meadow_draw(&game->meadow, &game->world, renderer, game->camera.position);

    /* Showing where the firewood goes: the spot, on the ground in front of you */
    if (follower(game, SHOW_PLACE) && game->marker.vao) {
        const Object *p = game->player;
        Vec3 at = vec3_add(p->position, vec3_scale(Object_facing(p), TALK_PILE_AHEAD));
        at.y = Terrain_height(&game->world.terrain, at.x, at.z) + 0.04f;
        Mat4 model = mat4_mul(mat4_translate(at), mat4_rotate_y(p->rotation.y));
        Renderer_drawDecal(renderer, &game->marker, model, TERRAIN_SIZE);
    }
    /* Who or what to go to next: a golden diamond over it, bobbing and turning */
    Vec3 quest;
    if (game->quest_mesh.vao && Game_questMarker(game, &quest)) {
        float t = game->quest_t;
        Mat4 model = mat4_mul(mat4_translate(vec3_add(quest, vec3(0.0f, 0.08f * sinf(t * 2.6f), 0.0f))), mat4_rotate_y(t * 1.8f));
        Renderer_drawMeshTinted(renderer, &game->quest_mesh, model, vec3(1.0f, 1.0f, 1.0f), 0.55f);
    }
}