Player movement

Author Mitja Felicijan <mitja.felicijan@gmail.com> 2026-04-28 10:00:50 +0200
Committer Mitja Felicijan <mitja.felicijan@gmail.com> 2026-04-28 10:25:31 +0200
Commit 5a4aec1442cf06c43e2581128ecd2667ab525ea2 (patch)
-rw-r--r-- Makefile 4
-rw-r--r-- all.h 19
-rw-r--r-- config.h 18
-rw-r--r-- game.c 72
-rw-r--r-- player.c 282
5 files changed, 352 insertions, 43 deletions
diff --git a/Makefile b/Makefile
...
16
GAME         := bin/stalag
16
GAME         := bin/stalag
17
HEXDUMP      := bin/hexdump
17
HEXDUMP      := bin/hexdump
18
PACKER       := bin/packer
18
PACKER       := bin/packer
19
SOURCES      := main.c map.c game.c
19
SOURCES      := main.c map.c game.c player.c
20
  
20
  
21
ifeq ($(SYSTEM), linux_amd64)
21
ifeq ($(SYSTEM), linux_amd64)
22
	LDFLAGS += -lX11
22
	LDFLAGS += -lX11
...
47
	$(CC) -std=c99 -o $(PACKER) tools/packer.c
47
	$(CC) -std=c99 -o $(PACKER) tools/packer.c
48
  
48
  
49
data: $(PACKER)
49
data: $(PACKER)
50
	$(PACKER) -p data.pak textures maps
50
	$(PACKER) -p data.pak textures maps fonts
51
  
51
  
52
mkdirs:
52
mkdirs:
53
	mkdir -p bin
53
	mkdir -p bin
...
diff --git a/all.h b/all.h
...
77
  
77
  
78
// --- Game State ---
78
// --- Game State ---
79
  
79
  
  
80
typedef enum {
  
81
    MOVE_NORMAL,
  
82
    MOVE_FLY
  
83
} MovementMode;
  
84
  
80
typedef struct {
85
typedef struct {
81
    Camera camera;
86
    Camera camera;
82
    Model *world_models;
87
    Model *world_models;
83
    int world_model_count;
88
    int world_model_count;
84
    bool cursor_captured;
89
    bool cursor_captured;
85
    bool vsync;
90
    bool vsync;
  
91
    Font font_ui;
  
92
  
  
93
    MovementMode move_mode;
  
94
    Vector3 pos;
  
95
    Vector3 velocity;
  
96
    bool is_grounded;
  
97
    float yaw;
  
98
    float pitch;
  
99
    float lean_amount;
  
100
    float crouch_amount;
  
101
    float horizontal_speed;
86
} GameState;
102
} GameState;
87
  
103
  
88
extern GameState game;
104
extern GameState game;
...
113
void DrawGame(void);
129
void DrawGame(void);
114
bool LoadMap(const char *filename);
130
bool LoadMap(const char *filename);
115
void UnloadMap(void);
131
void UnloadMap(void);
  
132
  
  
133
// Player
  
134
void UpdatePlayer(void);
116
  
135
  
117
#endif
136
#endif
diff --git a/config.h b/config.h
...
5
#define WINDOW_HEIGHT 720
5
#define WINDOW_HEIGHT 720
6
#define WINDOW_TITLE "Stalag"
6
#define WINDOW_TITLE "Stalag"
7
  
7
  
8
#define PLAYER_MOVE_SPEED 400.0f
8
#define PLAYER_MOVE_SPEED 200.0f
  
9
#define PLAYER_FLY_SPEED 400.0f
9
#define PLAYER_ROTATION_SPEED 0.05f
10
#define PLAYER_ROTATION_SPEED 0.05f
10
#define PLAYER_MOUSE_SENSITIVITY 0.003f
11
#define PLAYER_MOUSE_SENSITIVITY 0.003f
  
12
#define PLAYER_SPRINT_MULTIPLIER 2.0f
  
13
  
  
14
#define PLAYER_GRAVITY 1200.0f
  
15
#define PLAYER_JUMP_FORCE 400.0f
  
16
#define PLAYER_HEIGHT 64.0f
  
17
#define PLAYER_RADIUS 16.0f
  
18
#define PLAYER_EYE_HEIGHT 56.0f
  
19
  
  
20
#define PLAYER_LEAN_ANGLE 25.0f
  
21
#define PLAYER_LEAN_SPEED 8.0f
  
22
#define PLAYER_LEAN_PIVOT_DISTANCE 30.0f
  
23
  
  
24
#define PLAYER_CROUCH_OFFSET 28.0f
  
25
#define PLAYER_CROUCH_SPEED 12.0f
  
26
#define PLAYER_CROUCH_MOVE_MULTIPLIER 0.5f
11
  
27
  
12
#endif
28
#endif
diff --git a/game.c b/game.c
...
112
                float x, y, z;
112
                float x, y, z;
113
                sscanf(e->properties[j].value, "%f %f %f", &x, &y, &z);
113
                sscanf(e->properties[j].value, "%f %f %f", &x, &y, &z);
114
                if (strcmp(classname, "info_player_start") == 0) {
114
                if (strcmp(classname, "info_player_start") == 0) {
115
                    game.camera.position = (Vector3){ x, z, -y };
115
                    game.pos = (Vector3){ x, z, -y };
116
                    game.camera.target = Vector3Add(game.camera.position, (Vector3){0, 0, 1});
116
                    game.camera.position = game.pos;
117
                    TraceLog(LOG_INFO, "Player spawn set to: %f, %f, %f", game.camera.position.x, game.camera.position.y, game.camera.position.z);
117
                    float angle = 0;
  
118
                    for (int k = 0; k < e->property_count; k++) {
  
119
                        if (strcmp(e->properties[k].key, "angle") == 0) {
  
120
                            angle = (float)atof(e->properties[k].value);
  
121
                        }
  
122
                    }
  
123
                    game.yaw = (angle + 90.0f) * DEG2RAD;
  
124
                    game.pitch = 0;
  
125
                    game.camera.target = Vector3Add(game.pos, (Vector3){sinf(game.yaw), 0, cosf(game.yaw)});
  
126
                    TraceLog(LOG_INFO, "Player spawn set to: %f, %f, %f (yaw: %f)", game.pos.x, game.pos.y, game.pos.z, game.yaw);
118
                }
127
                }
119
            }
128
            }
120
        }
129
        }
...
219
    game.world_models = NULL;
228
    game.world_models = NULL;
220
    game.world_model_count = 0;
229
    game.world_model_count = 0;
221
    
230
    
  
231
    // Load UI Font
  
232
    size_t font_size = 0;
  
233
    void *font_data = vfs_read("fonts/LiberationSans-Bold.ttf", &font_size);
  
234
    if (font_data) {
  
235
        game.font_ui = LoadFontFromMemory(".ttf", font_data, (int)font_size, 20, NULL, 0);
  
236
        vfs_free(font_data);
  
237
    } else {
  
238
        game.font_ui = GetFontDefault();
  
239
    }
  
240
  
222
    LoadMap("maps/demo1.map");
241
    LoadMap("maps/demo1.map");
223
  
242
  
224
    game.cursor_captured = false;
243
    game.cursor_captured = false;
225
    EnableCursor();
244
    EnableCursor();
  
245
  
  
246
    game.move_mode = MOVE_NORMAL;
  
247
    game.velocity = (Vector3){ 0, 0, 0 };
  
248
    game.is_grounded = false;
226
}
249
}
227
  
250
  
228
void UpdateGame(void) {
251
void UpdateGame(void) {
229
    float dt = GetFrameTime();
  
230
    
  
231
    if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) {
252
    if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) {
232
        game.cursor_captured = !game.cursor_captured;
253
        game.cursor_captured = !game.cursor_captured;
233
        if (game.cursor_captured) DisableCursor();
254
        if (game.cursor_captured) DisableCursor();
...
248
        }
269
        }
249
    }
270
    }
250
  
271
  
251
    // Manual first-person movement
272
    UpdatePlayer();
252
    Vector3 forward = Vector3Normalize(Vector3Subtract(game.camera.target, game.camera.position));
  
253
    Vector3 right = Vector3Normalize(Vector3CrossProduct(forward, game.camera.up));
  
254
    
  
255
    Vector3 move = { 0 };
  
256
    if (IsKeyDown(KEY_W)) move = Vector3Add(move, forward);
  
257
    if (IsKeyDown(KEY_S)) move = Vector3Subtract(move, forward);
  
258
    if (IsKeyDown(KEY_D)) move = Vector3Add(move, right);
  
259
    if (IsKeyDown(KEY_A)) move = Vector3Subtract(move, right);
  
260
    
  
261
    if (Vector3Length(move) > 0) {
  
262
        move = Vector3Scale(Vector3Normalize(move), PLAYER_MOVE_SPEED * dt);
  
263
        game.camera.position = Vector3Add(game.camera.position, move);
  
264
        game.camera.target = Vector3Add(game.camera.target, move);
  
265
    }
  
266
    
  
267
    if (game.cursor_captured) {
  
268
        // Manual rotation
  
269
        Vector2 mouseDelta = GetMouseDelta();
  
270
        float yaw = -mouseDelta.x * PLAYER_MOUSE_SENSITIVITY;
  
271
        float pitch = -mouseDelta.y * PLAYER_MOUSE_SENSITIVITY;
  
272
        
  
273
        Vector3 view = Vector3Subtract(game.camera.target, game.camera.position);
  
274
        
  
275
        // Yaw
  
276
        view = Vector3RotateByAxisAngle(view, game.camera.up, yaw);
  
277
        
  
278
        // Pitch
  
279
        Vector3 axis = Vector3Normalize(Vector3CrossProduct(view, game.camera.up));
  
280
        view = Vector3RotateByAxisAngle(view, axis, pitch);
  
281
        
  
282
        game.camera.target = Vector3Add(game.camera.position, view);
  
283
    }
  
284
}
273
}
285
  
274
  
286
void DrawGame(void) {
275
void DrawGame(void) {
...
300
    int screenHeight = GetScreenHeight();
289
    int screenHeight = GetScreenHeight();
301
    DrawLine(screenWidth / 2 - 10, screenHeight / 2, screenWidth / 2 + 10, screenHeight / 2, GREEN);
290
    DrawLine(screenWidth / 2 - 10, screenHeight / 2, screenWidth / 2 + 10, screenHeight / 2, GREEN);
302
    DrawLine(screenWidth / 2, screenHeight / 2 - 10, screenWidth / 2, screenHeight / 2 + 10, GREEN);
291
    DrawLine(screenWidth / 2, screenHeight / 2 - 10, screenWidth / 2, screenHeight / 2 + 10, GREEN);
303
    DrawFPS(10, 10);
292
    
304
    DrawText(TextFormat("VSync: %s", game.vsync ? "ON" : "OFF"), 10, 30, 20, GREEN);
293
    DrawTextEx(game.font_ui, TextFormat("%i FPS", GetFPS()), (Vector2){ 10, 10 }, 20, 2, GREEN);
  
294
    DrawTextEx(game.font_ui, TextFormat("VSync: %s", game.vsync ? "ON" : "OFF"), (Vector2){ 10, 35 }, 20, 2, GREEN);
  
295
    DrawTextEx(game.font_ui, TextFormat("Speed: %.0f", game.horizontal_speed), (Vector2){ 10, 60 }, 20, 2, GREEN);
  
296
  
305
    EndDrawing();
297
    EndDrawing();
306
}
298
}
diff --git a/player.c b/player.c
  
1
#include "all.h"
  
2
#include "raymath.h"
  
3
#include <float.h>
  
4
  
  
5
static void PlayerRotate(void) {
  
6
	if (!game.cursor_captured) return;
  
7
  
  
8
	Vector2 mouseDelta = GetMouseDelta();
  
9
	game.yaw += -mouseDelta.x * PLAYER_MOUSE_SENSITIVITY;
  
10
	game.pitch += mouseDelta.y * PLAYER_MOUSE_SENSITIVITY;
  
11
  
  
12
	// Clamp pitch to avoid gimbal lock/flipping (approx 89 degrees)
  
13
	if (game.pitch > 89.0f * DEG2RAD) game.pitch = 89.0f * DEG2RAD;
  
14
	if (game.pitch < -89.0f * DEG2RAD) game.pitch = -89.0f * DEG2RAD;
  
15
}
  
16
  
  
17
static bool CheckWorldCollision(Vector3 start, Vector3 end, RayCollision *outCollision) {
  
18
	Vector3 diff = Vector3Subtract(end, start);
  
19
	float maxDist = Vector3Length(diff);
  
20
	if (maxDist < 0.001f) return false;
  
21
  
  
22
	Ray ray = { 0 };
  
23
	ray.position = start;
  
24
	ray.direction = Vector3Scale(diff, 1.0f / maxDist);
  
25
  
  
26
	RayCollision closest = { 0 };
  
27
	closest.distance = FLT_MAX;
  
28
	closest.hit = false;
  
29
  
  
30
	for (int i = 0; i < game.world_model_count; i++) {
  
31
		Model model = game.world_models[i];
  
32
		for (int m = 0; m < model.meshCount; m++) {
  
33
			RayCollision col = GetRayCollisionMesh(ray, model.meshes[m], model.transform);
  
34
			if (col.hit && col.distance < closest.distance && col.distance <= maxDist) {
  
35
				closest = col;
  
36
			}
  
37
		}
  
38
	}
  
39
  
  
40
	if (closest.hit) {
  
41
		if (outCollision) *outCollision = closest;
  
42
		return true;
  
43
	}
  
44
	return false;
  
45
}
  
46
  
  
47
static void MoveNormal(float dt) {
  
48
	float eyeHeight = PLAYER_EYE_HEIGHT - (game.crouch_amount * PLAYER_CROUCH_OFFSET);
  
49
  
  
50
	// Movement vectors based on yaw
  
51
	Vector3 forward = { sinf(game.yaw), 0, cosf(game.yaw) };
  
52
	Vector3 right = { sinf(game.yaw - PI/2.0f), 0, cosf(game.yaw - PI/2.0f) };
  
53
  
  
54
	Vector3 moveDir = { 0 };
  
55
	if (IsKeyDown(KEY_W)) moveDir = Vector3Add(moveDir, forward);
  
56
	if (IsKeyDown(KEY_S)) moveDir = Vector3Subtract(moveDir, forward);
  
57
	if (IsKeyDown(KEY_D)) moveDir = Vector3Add(moveDir, right);
  
58
	if (IsKeyDown(KEY_A)) moveDir = Vector3Subtract(moveDir, right);
  
59
  
  
60
	float speed = PLAYER_MOVE_SPEED;
  
61
	if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) speed *= PLAYER_SPRINT_MULTIPLIER;
  
62
  
  
63
	// Apply crouch speed penalty
  
64
	speed *= Lerp(1.0f, PLAYER_CROUCH_MOVE_MULTIPLIER, game.crouch_amount);
  
65
  
  
66
	if (Vector3Length(moveDir) > 0) {
  
67
		moveDir = Vector3Scale(Vector3Normalize(moveDir), speed * dt);
  
68
	}
  
69
  
  
70
	// Apply gravity
  
71
	game.velocity.y -= PLAYER_GRAVITY * dt;
  
72
  
  
73
	// Jump
  
74
	if (game.is_grounded && IsKeyPressed(KEY_SPACE)) {
  
75
		game.velocity.y = PLAYER_JUMP_FORCE;
  
76
		game.is_grounded = false;
  
77
	}
  
78
  
  
79
	// Horizontal movement with sliding collision
  
80
	Vector3 remainingMove = moveDir;
  
81
	for (int iter = 0; iter < 4 && Vector3Length(remainingMove) > 0.001f; iter++) {
  
82
		RayCollision closestHit = { 0 };
  
83
		closestHit.distance = FLT_MAX;
  
84
		bool hitFound = false;
  
85
  
  
86
		float heights[] = { -eyeHeight + 10.0f, -eyeHeight / 2.0f, 0.0f };
  
87
		Vector3 currentPos = game.pos;
  
88
  
  
89
		for (int i = 0; i < 3; i++) {
  
90
			Vector3 start = Vector3Add(currentPos, (Vector3){0, heights[i], 0});
  
91
			Vector3 dir = Vector3Normalize(remainingMove);
  
92
			float dist = Vector3Length(remainingMove) + PLAYER_RADIUS;
  
93
			Vector3 end = Vector3Add(start, Vector3Scale(dir, dist));
  
94
  
  
95
			RayCollision col;
  
96
			if (CheckWorldCollision(start, end, &col)) {
  
97
				// Adjust distance to be relative to the player boundary
  
98
				float adjustedDist = col.distance - PLAYER_RADIUS;
  
99
				if (adjustedDist < closestHit.distance) {
  
100
					closestHit = col;
  
101
					closestHit.distance = adjustedDist;
  
102
					hitFound = true;
  
103
				}
  
104
			}
  
105
		}
  
106
  
  
107
		if (hitFound) {
  
108
			// Move as far as possible
  
109
			float moveDist = fmaxf(0, closestHit.distance - 0.1f);
  
110
			Vector3 moveStep = Vector3Scale(Vector3Normalize(remainingMove), moveDist);
  
111
			game.pos.x += moveStep.x;
  
112
			game.pos.z += moveStep.z;
  
113
  
  
114
			// Project remaining movement onto the plane of the wall
  
115
			Vector3 slideNormal = { closestHit.normal.x, 0, closestHit.normal.z };
  
116
			if (Vector3Length(slideNormal) > 0.001f) {
  
117
				slideNormal = Vector3Normalize(slideNormal);
  
118
				remainingMove = Vector3Subtract(remainingMove, moveStep);
  
119
				float dot = Vector3DotProduct(remainingMove, slideNormal);
  
120
				remainingMove = Vector3Subtract(remainingMove, Vector3Scale(slideNormal, dot));
  
121
			} else {
  
122
				remainingMove = (Vector3){0, 0, 0};
  
123
			}
  
124
		} else {
  
125
			// No collision found, move the rest of the way
  
126
			game.pos.x += remainingMove.x;
  
127
			game.pos.z += remainingMove.z;
  
128
			break;
  
129
		}
  
130
	}
  
131
  
  
132
	// Vertical movement with collision
  
133
	float verticalMove = game.velocity.y * dt;
  
134
	Vector3 vStart = game.pos;
  
135
  
  
136
	if (verticalMove < 0) { // Falling/Down
  
137
		Vector3 start = vStart;
  
138
		// Check slightly below feet
  
139
		Vector3 end = Vector3Add(vStart, (Vector3){0, verticalMove - eyeHeight, 0});
  
140
		RayCollision vCol;
  
141
		if (CheckWorldCollision(start, end, &vCol) && vCol.normal.y > 0.5f) {
  
142
			game.pos.y = vCol.point.y + eyeHeight;
  
143
			game.velocity.y = 0;
  
144
			game.is_grounded = true;
  
145
		} else {
  
146
			game.pos.y += verticalMove;
  
147
			game.is_grounded = false;
  
148
		}
  
149
	} else if (verticalMove > 0) { // Jumping/Up
  
150
		Vector3 start = vStart;
  
151
		// Check above head
  
152
		float headHeight = PLAYER_HEIGHT - PLAYER_EYE_HEIGHT;
  
153
		Vector3 end = Vector3Add(vStart, (Vector3){0, verticalMove + headHeight, 0});
  
154
		RayCollision vCol;
  
155
		if (CheckWorldCollision(start, end, &vCol)) {
  
156
			game.pos.y = vCol.point.y - headHeight - 1.0f;
  
157
			game.velocity.y = 0;
  
158
		} else {
  
159
			game.pos.y += verticalMove;
  
160
		}
  
161
		game.is_grounded = false;
  
162
	} else {
  
163
		// Not moving vertically, but check if we are still on ground
  
164
		Vector3 start = vStart;
  
165
		Vector3 end = Vector3Add(vStart, (Vector3){0, -eyeHeight - 2.0f, 0});
  
166
		RayCollision vCol;
  
167
		if (CheckWorldCollision(start, end, &vCol) && vCol.normal.y > 0.5f) {
  
168
			game.is_grounded = true;
  
169
			// Snap to floor if very close
  
170
			if (vCol.distance < eyeHeight + 1.0f) {
  
171
				game.pos.y = vCol.point.y + eyeHeight;
  
172
			}
  
173
		} else {
  
174
			game.is_grounded = false;
  
175
		}
  
176
	}
  
177
}
  
178
  
  
179
static void MoveFly(float dt) {
  
180
	// Full 3D movement based on yaw/pitch
  
181
	Vector3 forward = { 
  
182
		cosf(game.pitch) * sinf(game.yaw), 
  
183
		-sinf(game.pitch), 
  
184
		cosf(game.pitch) * cosf(game.yaw) 
  
185
	};
  
186
	Vector3 right = { sinf(game.yaw - PI/2.0f), 0, cosf(game.yaw - PI/2.0f) };
  
187
  
  
188
	Vector3 move = { 0 };
  
189
	if (IsKeyDown(KEY_W)) move = Vector3Add(move, forward);
  
190
	if (IsKeyDown(KEY_S)) move = Vector3Subtract(move, forward);
  
191
	if (IsKeyDown(KEY_D)) move = Vector3Add(move, right);
  
192
	if (IsKeyDown(KEY_A)) move = Vector3Subtract(move, right);
  
193
  
  
194
	// Fly up/down
  
195
	if (IsKeyDown(KEY_SPACE)) move.y += 1.0f;
  
196
	if (IsKeyDown(KEY_LEFT_CONTROL)) move.y -= 1.0f;
  
197
  
  
198
	float speed = PLAYER_FLY_SPEED;
  
199
	if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) speed *= PLAYER_SPRINT_MULTIPLIER;
  
200
  
  
201
	if (Vector3Length(move) > 0) {
  
202
		move = Vector3Scale(Vector3Normalize(move), speed * dt);
  
203
		game.pos = Vector3Add(game.pos, move);
  
204
	}
  
205
  
  
206
	game.velocity = (Vector3){0, 0, 0};
  
207
	game.is_grounded = false;
  
208
}
  
209
  
  
210
void UpdatePlayer(void) {
  
211
	float dt = GetFrameTime();
  
212
	Vector3 oldPos = game.pos;
  
213
  
  
214
	if (IsKeyPressed(KEY_F)) {
  
215
		game.move_mode = (game.move_mode == MOVE_NORMAL) ? MOVE_FLY : MOVE_NORMAL;
  
216
		TraceLog(LOG_INFO, "Movement mode: %s", (game.move_mode == MOVE_NORMAL) ? "NORMAL" : "FLY");
  
217
	}
  
218
  
  
219
	PlayerRotate();
  
220
  
  
221
	// Crouching logic
  
222
	bool canStand = true;
  
223
	if (game.crouch_amount > 0.1f) {
  
224
		Vector3 headPos = game.pos;
  
225
		Vector3 headEnd = Vector3Add(headPos, (Vector3){0, PLAYER_HEIGHT - PLAYER_EYE_HEIGHT + PLAYER_CROUCH_OFFSET, 0});
  
226
		RayCollision col;
  
227
		if (CheckWorldCollision(headPos, headEnd, &col)) {
  
228
			canStand = false;
  
229
		}
  
230
	}
  
231
  
  
232
	float targetCrouch = 0.0f;
  
233
	if (IsKeyDown(KEY_LEFT_CONTROL) || !canStand) targetCrouch = 1.0f;
  
234
	game.crouch_amount = Lerp(game.crouch_amount, targetCrouch, PLAYER_CROUCH_SPEED * dt);
  
235
  
  
236
	// Leaning logic
  
237
	float targetLean = 0.0f;
  
238
	if (IsKeyDown(KEY_Q)) targetLean -= 1.0f;
  
239
	if (IsKeyDown(KEY_E)) targetLean += 1.0f;
  
240
  
  
241
	game.lean_amount = Lerp(game.lean_amount, targetLean, PLAYER_LEAN_SPEED * dt);
  
242
  
  
243
	if (game.move_mode == MOVE_FLY) {
  
244
		MoveFly(dt);
  
245
	} else {
  
246
		MoveNormal(dt);
  
247
	}
  
248
  
  
249
	// Apply lean as a pivot from the neck/waist
  
250
	float leanAngle = game.lean_amount * PLAYER_LEAN_ANGLE * DEG2RAD;
  
251
	Vector3 bodyForward = { sinf(game.yaw), 0, cosf(game.yaw) };
  
252
  
  
253
	float currentEyeHeight = PLAYER_EYE_HEIGHT - (game.crouch_amount * PLAYER_CROUCH_OFFSET);
  
254
	float pivotDist = fminf(PLAYER_LEAN_PIVOT_DISTANCE, currentEyeHeight * 0.8f);
  
255
  
  
256
	Vector3 neckToEye = { 0, pivotDist, 0 };
  
257
	Vector3 rotatedOffset = Vector3RotateByAxisAngle(neckToEye, bodyForward, leanAngle);
  
258
	Vector3 pivot = Vector3Subtract(game.pos, (Vector3){ 0, pivotDist, 0 });
  
259
  
  
260
	// Update camera based on physical pos + visual lean pivot
  
261
	game.camera.position = Vector3Add(pivot, rotatedOffset);
  
262
  
  
263
	// Apply roll to up vector
  
264
	Vector3 forward = {
  
265
		cosf(game.pitch) * sinf(game.yaw),
  
266
		-sinf(game.pitch),
  
267
		cosf(game.pitch) * cosf(game.yaw)
  
268
	};
  
269
	Vector3 up = { 0, 1, 0 };
  
270
	up = Vector3RotateByAxisAngle(up, forward, leanAngle);
  
271
	game.camera.up = up;
  
272
  
  
273
	game.camera.target = Vector3Add(game.camera.position, forward);
  
274
  
  
275
	// Calculate horizontal speed for UI
  
276
	if (dt > 0) {
  
277
		Vector2 velH = { game.pos.x - oldPos.x, game.pos.z - oldPos.z };
  
278
		game.horizontal_speed = Vector2Length(velH) / dt;
  
279
	} else {
  
280
		game.horizontal_speed = 0;
  
281
	}
  
282
}