Enhancements to breadcrumb trail

This commit is contained in:
2026-05-19 17:36:42 -04:00
parent 7235d756dc
commit 186d9f673a
16 changed files with 514 additions and 117 deletions

View File

@@ -1,7 +1,7 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Project Structure & Module Organization
Core gameplay lives in root-level C++ translation units: `ghostland.cpp` orchestrates the loop, `player.cpp`, `ghost.cpp`, `collisions.cpp`, `shader.cpp`, `text.cpp`, and `config.cpp` own their respective systems. Runtime assets stay alongside code: GLSL shaders (`*shader.glsl`), UI textures (PNG in the repo root), and bitmap fonts under `fonts/`. Level data is serialized in `maze.txt`, typically regenerated via `mazeparser.py` from `maze.png`. Adjustable settings belong in `ghostland.json`; document experimental JSON entries before relying on them in code to avoid null lookups. Core gameplay lives in root-level C++ translation units: `ghostland.cpp` orchestrates the loop, `player.cpp`, `ghost.cpp`, `collisions.cpp`, `shader.cpp`, `text.cpp`, and `config.cpp` own their respective systems. Runtime assets stay alongside code: GLSL shaders (`*shader.glsl`), UI textures (PNG in the repo root), and bitmap fonts under `fonts/`. Level data is serialized in `maze.txt`, typically regenerated via `mazeparser.py` from `maze.png`. Adjustable settings belong in `ghostland.json`; keep defaults mirrored in code so omitted keys never cause null or invalid lookups.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `make` builds all objects with C++17 + O3 and links the `ghostland` binary with GLFW, GLAD, stb_image, and Freetype. - `make` builds all objects with C++17 + O3 and links the `ghostland` binary with GLFW, GLAD, stb_image, and Freetype.
@@ -12,6 +12,15 @@ Core gameplay lives in root-level C++ translation units: `ghostland.cpp` orchest
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use 4-space indentation, braces on the same line as control statements, and guard new modules with include guards. Keep standard-library/GLM includes before project headers as in `ghostland.cpp`. Classes use PascalCase (`Player`), local variables use `snake_case`, compile-time constants stay uppercase. Prefer STL containers over manual arrays unless performance-critical, and keep shader uniform names centralized near the top of `ghostland.cpp`. Use 4-space indentation, braces on the same line as control statements, and guard new modules with include guards. Keep standard-library/GLM includes before project headers as in `ghostland.cpp`. Classes use PascalCase (`Player`), local variables use `snake_case`, compile-time constants stay uppercase. Prefer STL containers over manual arrays unless performance-critical, and keep shader uniform names centralized near the top of `ghostland.cpp`.
## Configuration Guidelines
`ghostland.json` is intentionally a flat runtime tuning file. The parser in `config.cpp` is a minimal line-oriented JSON subset reader: it expects one `"key": value` pair per line, ignores braces and comment-like lines, strips one trailing comma, and stores raw strings in a process-wide map. Do not rely on nested objects, arrays, inline comments after values, duplicate-key semantics beyond "last loaded wins", or full JSON validation unless you first replace the parser deliberately.
Load config values once during startup after `Config::loadFromFile("ghostland.json")`, then copy them into typed globals or small config structs. Always use `Config::getFloat`, `Config::getInt`, or `Config::getString` with a meaningful in-code default; missing or malformed values should keep the game playable. Keep booleans as `0` / `1` integer keys for consistency with existing options.
When adding or renaming a setting, update all three surfaces together: the struct or default value in code, the sample value in `ghostland.json`, and the README configuration table/example. Prefer descriptive key names grouped by system prefix (`minimap_*`, `trail_*`, `ghost_*`). If compatibility matters, read the legacy key first and let the new key override it, as with `trail_y_offset` falling back to `trail_y_position`.
Validate numeric config values at the use site when bad ranges can break rendering or gameplay. Clamp or guard zero/negative values where appropriate (`std::max` for lifetimes, spacing, sizes, and shader divisors), and keep color channels documented as 0-1 floats. Avoid adding config knobs for internal implementation details unless they are useful for tuning gameplay, visuals, or reproduction.
## Testing Guidelines ## Testing Guidelines
There is no automated framework yet, so rely on focused manual passes. Before opening a PR, build fresh (`make clean && make`), run `./ghostland`, and verify spawn position, collision bounds, ghost ordering, and text rendering. When touching geometry, regenerate `maze.txt` and inspect extremes logged at the bottom of the file. Mention any GPU or driver quirks encountered so others can reproduce. There is no automated framework yet, so rely on focused manual passes. Before opening a PR, build fresh (`make clean && make`), run `./ghostland`, and verify spawn position, collision bounds, ghost ordering, and text rendering. When touching geometry, regenerate `maze.txt` and inspect extremes logged at the bottom of the file. Mention any GPU or driver quirks encountered so others can reproduce.
@@ -20,6 +29,16 @@ Existing history favors short, descriptive subjects ("Player's position is reset
## Minimap Implementation Plan ## Minimap Implementation Plan
- Add minimap config keys in `ghostland.json` (enable flag, width/height, margin, colors). After loading `maze.txt`, cache 2D wall segments plus world bounds for quick reuse. - Add minimap config keys in `ghostland.json` (enable flag, width/height, margin, colors). After loading `maze.txt`, cache 2D wall segments plus world bounds for quick reuse.
- Create a lightweight 2D orthographic shader/VAO for overlay drawing; use GL_LINES for walls, triangle-fan circles for breadcrumbs/ghosts, and a small triangle for the player arrow. Disable depth test while rendering the overlay near the end of the frame. - Create a lightweight 2D orthographic shader/VAO for overlay drawing; use GL_LINES for walls, triangle quads for the trail stroke, triangle-fan circles for ghosts, and a small triangle for the player arrow. Disable depth test while rendering the overlay near the end of the frame.
- Each frame convert wall endpoints and breadcrumb/ghost positions relative to the players X/Z so the minimap scrolls with the player centered. Clip or skip segments outside the visible minimap extents. - Each frame convert wall endpoints, trail points, and ghost positions relative to the players X/Z so the minimap scrolls with the player centered. Clip or skip segments outside the visible minimap extents.
- Draw order: white background quad, wall lines, breadcrumb dots, ghost dots (border then fill), red arrow rotated by player yaw. Re-enable depth test and continue with HUD text. - Draw order: white background quad, wall lines, trail stroke, ghost dots (border then fill), red arrow rotated by player yaw. Re-enable depth test and continue with HUD text.
## Trail Implementation Overview
The old breadcrumb arrows have been replaced by a continuous time-limited trail rendered as a flat ribbon mesh in world space. `ghostland.cpp` owns the trail data structures: `TrailPoint` stores sampled X/Z player positions, timestamps, and a `starts_segment` flag that prevents respawns from drawing teleport lines. `TrailVertex` stores generated ribbon vertices plus the source timestamp. `update_trail()` prunes samples older than `trail_lifetime_minutes`, adds a new sample only after the player moves at least `trail_sample_spacing`, and removes nearly straight intermediate samples using `trail_simplify_epsilon`.
`rebuild_trail_mesh()` turns adjacent trail samples into quad segments made from two triangles, avoiding driver-dependent OpenGL line width behavior. The VBO is updated only when samples are added, pruned, simplified, or a reset splits the trail. `trailshader.glsl` passes each vertex timestamp to `trailfragshader.glsl`, which blends from `trail_recent_*` color values toward `trail_old_*` based on age. Keep the shader GLSL version aligned with the requested OpenGL 3.3 context.
Trail tuning belongs in `ghostland.json`: `trail_enabled`, `trail_lifetime_minutes`, `trail_sample_spacing`, `trail_width`, `trail_y_position`, `trail_simplify_epsilon`, `trail_reset_marker_size`, and RGB triplets for recent/old colors. The legacy `trail_y_offset` key is still accepted as a fallback. The minimap consumes the same trail point deque and renders a screen-space stroke whose half-width is controlled by `minimap_breadcrumb_radius`, so changes to sampling behavior affect both the world ribbon and overlay path.
## Danger Tint Notes
The red danger tint is driven by the nearest ghost in X/Z space rather than 3D distance, so ghost bobbing does not change the warning. Keep the tint calculation independent of ghost render ordering: the partial sort is only for drawing. The tint also keeps persistent intensity with a 30-unit enter / 35-unit exit hysteresis band and frame-rate-independent smoothing; this prevents threshold jitter from flashing the camera between normal and red.

View File

@@ -23,6 +23,7 @@ While the previous project was implemented in go, for this project I was not abl
| `minimap_breadcrumb_[rgb]` | float | `1.0 / 0.3 / 0.3` | Breadcrumb dot color per channel (01). | | `minimap_breadcrumb_[rgb]` | float | `1.0 / 0.3 / 0.3` | Breadcrumb dot color per channel (01). |
| `minimap_breadcrumb_radius` | float | `3.0` | Breadcrumb radius in pixels. | | `minimap_breadcrumb_radius` | float | `3.0` | Breadcrumb radius in pixels. |
| `breadcrumb_max_distance` | float | `200.0` | Bread crumbs beyond this world-space distance from the player are not drawn (both in-world and on the minimap). | | `breadcrumb_max_distance` | float | `200.0` | Bread crumbs beyond this world-space distance from the player are not drawn (both in-world and on the minimap). |
| `trail_y_position` | float | `0.06` | World-space Y coordinate used to draw the trail ribbon and reset marker. `trail_y_offset` is still accepted as a legacy fallback. |
| `minimap_ghost_fill_[rgb]` | float | `0.9 / 0.9 / 1.0` | Ghost dot interior color on the minimap. | | `minimap_ghost_fill_[rgb]` | float | `0.9 / 0.9 / 1.0` | Ghost dot interior color on the minimap. |
| `minimap_ghost_border_[rgb]` | float | `0.2 / 0.2 / 0.4` | Ghost dot border color. | | `minimap_ghost_border_[rgb]` | float | `0.2 / 0.2 / 0.4` | Ghost dot border color. |
| `minimap_ghost_radius` | float | `4.0` | Ghost indicator radius in pixels. | | `minimap_ghost_radius` | float | `4.0` | Ghost indicator radius in pixels. |
@@ -54,6 +55,7 @@ Example:
"minimap_breadcrumb_b": 0.3, "minimap_breadcrumb_b": 0.3,
"minimap_breadcrumb_radius": 3.0, "minimap_breadcrumb_radius": 3.0,
"breadcrumb_max_distance": 200.0, "breadcrumb_max_distance": 200.0,
"trail_y_position": 0.06,
"minimap_ghost_fill_r": 0.9, "minimap_ghost_fill_r": 0.9,
"minimap_ghost_fill_g": 0.9, "minimap_ghost_fill_g": 0.9,
"minimap_ghost_fill_b": 1.0, "minimap_ghost_fill_b": 1.0,

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Phong lighting fragment shader driven by a single spotlight described in ghostland.cpp. // Phong lighting fragment shader driven by a single spotlight described in ghostland.cpp.
out vec4 FragColor; out vec4 FragColor;

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Ghost sprite fragment shader: samples alpha texture and fades opacity to 30%. // Ghost sprite fragment shader: samples alpha texture and fades opacity to 30%.
out vec4 FragColor; out vec4 FragColor;

View File

@@ -14,6 +14,7 @@
#include <iostream> #include <iostream>
#include <filesystem> #include <filesystem>
#include <vector> #include <vector>
#include <deque>
#include <math.h> #include <math.h>
#include "collisions.h" #include "collisions.h"
@@ -28,7 +29,6 @@ void framebuffer_size_callback(GLFWwindow* window, int width, int height);
void mouse_callback(GLFWwindow* window, double xpos, double ypos); void mouse_callback(GLFWwindow* window, double xpos, double ypos);
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset); void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
void set_light_front(int xoffset, int yoffset); void set_light_front(int xoffset, int yoffset);
void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::vec3 *trail_positions, int trail_sz, const std::vector<Ghost *> &ghosts);
// player // player
Player *player; Player *player;
@@ -76,7 +76,52 @@ struct MinimapBounds {
float zmax = 0.0f; float zmax = 0.0f;
}; };
struct TrailConfig {
bool enabled = true;
float lifetime_minutes = 3.0f;
float sample_spacing = 1.0f;
float width = 0.25f;
float y_position = 0.06f;
float simplify_epsilon = 0.15f;
float reset_marker_size = 2.0f;
glm::vec3 recent_color = glm::vec3(1.0f, 0.0f, 0.0f);
glm::vec3 old_color = glm::vec3(0.35f, 0.18f, 0.05f);
};
struct TrailPoint {
glm::vec3 position;
float timestamp;
// True for the first point after spawn/reset; prevents drawing teleport segments.
bool starts_segment;
};
struct TrailVertex {
glm::vec3 position;
float timestamp;
};
struct TrailResetMarker {
glm::vec3 position;
// Player yaw at reset time, used to orient the flattened marker sprite.
float yaw;
float timestamp;
};
void render_minimap(const glm::vec3 &player_pos, float player_yaw, const std::deque<TrailPoint> &trail_points, const std::vector<Ghost *> &ghosts);
bool update_trail(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
bool end_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
bool begin_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
void add_trail_reset_marker(std::deque<TrailResetMarker> &reset_markers, const glm::vec3 &player_pos, float player_yaw, float current_time);
bool prune_trail_reset_markers(std::deque<TrailResetMarker> &reset_markers, float current_time);
void render_trail_reset_markers(int ghost_program, unsigned int ghostVAO, const std::deque<TrailResetMarker> &reset_markers);
void rebuild_trail_mesh(const std::deque<TrailPoint> &trail_points, std::vector<TrailVertex> &trail_vertices);
float distance_xz(const glm::vec3 &a, const glm::vec3 &b);
bool is_redundant_trail_point(const TrailPoint &a, const TrailPoint &b, const TrailPoint &c);
glm::vec2 trail_segment_direction(const std::deque<TrailPoint> &trail_points, size_t from_ix, size_t to_ix);
glm::vec3 trail_join_offset(const std::deque<TrailPoint> &trail_points, size_t point_ix, const glm::vec2 &fallback_normal, float half_width);
MinimapConfig minimap_config; MinimapConfig minimap_config;
TrailConfig trail_config;
std::vector<MinimapSegment> minimap_segments; std::vector<MinimapSegment> minimap_segments;
MinimapBounds minimap_bounds; MinimapBounds minimap_bounds;
unsigned int minimapVAO = 0; unsigned int minimapVAO = 0;
@@ -87,7 +132,6 @@ const char *projectionC = "projection";
const char *viewC = "view"; const char *viewC = "view";
const char *modelC = "model"; const char *modelC = "model";
const char *objectcolorC = "objectcolor";
const char *viewposC = "viewPos"; const char *viewposC = "viewPos";
const char *shininessC = "material.shininess"; const char *shininessC = "material.shininess";
const char *colorC = "material.color"; const char *colorC = "material.color";
@@ -102,8 +146,6 @@ const char *specularC = "light.specular";
int num_walls; int num_walls;
float *wall_vertices; float *wall_vertices;
int trailmax = 500;
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
int success; int success;
@@ -134,6 +176,20 @@ int main(int argc, char *argv[]) {
minimap_config.breadcrumb_color.b = Config::getFloat("minimap_breadcrumb_b", minimap_config.breadcrumb_color.b); minimap_config.breadcrumb_color.b = Config::getFloat("minimap_breadcrumb_b", minimap_config.breadcrumb_color.b);
minimap_config.breadcrumb_radius = Config::getFloat("minimap_breadcrumb_radius", minimap_config.breadcrumb_radius); minimap_config.breadcrumb_radius = Config::getFloat("minimap_breadcrumb_radius", minimap_config.breadcrumb_radius);
minimap_config.breadcrumb_max_distance = Config::getFloat("breadcrumb_max_distance", minimap_config.breadcrumb_max_distance); minimap_config.breadcrumb_max_distance = Config::getFloat("breadcrumb_max_distance", minimap_config.breadcrumb_max_distance);
trail_config.enabled = Config::getInt("trail_enabled", trail_config.enabled ? 1 : 0) != 0;
trail_config.lifetime_minutes = Config::getFloat("trail_lifetime_minutes", trail_config.lifetime_minutes);
trail_config.sample_spacing = Config::getFloat("trail_sample_spacing", trail_config.sample_spacing);
trail_config.width = Config::getFloat("trail_width", trail_config.width);
trail_config.y_position = Config::getFloat("trail_y_offset", trail_config.y_position);
trail_config.y_position = Config::getFloat("trail_y_position", trail_config.y_position);
trail_config.simplify_epsilon = Config::getFloat("trail_simplify_epsilon", trail_config.simplify_epsilon);
trail_config.reset_marker_size = Config::getFloat("trail_reset_marker_size", trail_config.reset_marker_size);
trail_config.recent_color.r = Config::getFloat("trail_recent_r", trail_config.recent_color.r);
trail_config.recent_color.g = Config::getFloat("trail_recent_g", trail_config.recent_color.g);
trail_config.recent_color.b = Config::getFloat("trail_recent_b", trail_config.recent_color.b);
trail_config.old_color.r = Config::getFloat("trail_old_r", trail_config.old_color.r);
trail_config.old_color.g = Config::getFloat("trail_old_g", trail_config.old_color.g);
trail_config.old_color.b = Config::getFloat("trail_old_b", trail_config.old_color.b);
minimap_config.ghost_fill_color.r = Config::getFloat("minimap_ghost_fill_r", minimap_config.ghost_fill_color.r); minimap_config.ghost_fill_color.r = Config::getFloat("minimap_ghost_fill_r", minimap_config.ghost_fill_color.r);
minimap_config.ghost_fill_color.g = Config::getFloat("minimap_ghost_fill_g", minimap_config.ghost_fill_color.g); minimap_config.ghost_fill_color.g = Config::getFloat("minimap_ghost_fill_g", minimap_config.ghost_fill_color.g);
minimap_config.ghost_fill_color.b = Config::getFloat("minimap_ghost_fill_b", minimap_config.ghost_fill_color.b); minimap_config.ghost_fill_color.b = Config::getFloat("minimap_ghost_fill_b", minimap_config.ghost_fill_color.b);
@@ -269,6 +325,9 @@ int main(int argc, char *argv[]) {
return -1; return -1;
} }
printf("OpenGL version: %s\n", glGetString(GL_VERSION));
printf("GLSL version: %s\n", glGetString(GL_SHADING_LANGUAGE_VERSION));
glEnable(GL_DEPTH_TEST); glEnable(GL_DEPTH_TEST);
//glEnable(GL_CULL_FACE); //glEnable(GL_CULL_FACE);
glEnable(GL_BLEND); glEnable(GL_BLEND);
@@ -282,7 +341,7 @@ int main(int argc, char *argv[]) {
// Perhaps there's a better way of doing this, rather than re-compiling the same files? // Perhaps there's a better way of doing this, rather than re-compiling the same files?
int floor_program = create_shader_program("vertexshader.glsl", "fragmentshader.glsl"); int floor_program = create_shader_program("vertexshader.glsl", "fragmentshader.glsl");
if (wall_program < 0) { if (floor_program < 0) {
glfwTerminate(); glfwTerminate();
return -1; return -1;
} }
@@ -323,21 +382,8 @@ int main(int argc, char *argv[]) {
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
glEnableVertexAttribArray(1); glEnableVertexAttribArray(1);
// do stuff for trail // The trail is a dynamic ribbon mesh. Each vertex carries the timestamp of
float trail_vertices[] = { // its source path sample so the shader can fade red-to-brown by age.
0.25f, 0.0f, 0.0f,
-0.25f, 0.0f, 0.5f,
-0.25f, 0.0f, -0.5f,
-0.25f, 0.0f, 0.1f,
-0.25f, 0.0f, -0.1f,
-0.75f, 0.0f, 0.1f,
-0.75f, 0.0f, 0.1f,
-0.75f, 0.0f, -0.1f,
-0.25f, 0.0f, -0.1f,
};
unsigned int trailVBO, trailVAO; unsigned int trailVBO, trailVAO;
glGenVertexArrays(1, &trailVAO); glGenVertexArrays(1, &trailVAO);
glGenBuffers(1, &trailVBO); glGenBuffers(1, &trailVBO);
@@ -345,10 +391,12 @@ int main(int argc, char *argv[]) {
glBindVertexArray(trailVAO); glBindVertexArray(trailVAO);
glBindBuffer(GL_ARRAY_BUFFER, trailVBO); glBindBuffer(GL_ARRAY_BUFFER, trailVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 3 * 3 * 3, trail_vertices, GL_STATIC_DRAW); glBufferData(GL_ARRAY_BUFFER, sizeof(TrailVertex), NULL, GL_DYNAMIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0); glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(TrailVertex), (void *)0);
glEnableVertexAttribArray(0); glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, sizeof(TrailVertex), (void *)(3 * sizeof(float)));
glEnableVertexAttribArray(1);
int trail_program = create_shader_program("trailshader.glsl", "trailfragshader.glsl"); int trail_program = create_shader_program("trailshader.glsl", "trailfragshader.glsl");
if (trail_program < 0) { if (trail_program < 0) {
@@ -356,10 +404,10 @@ int main(int argc, char *argv[]) {
return -1; return -1;
} }
glm::vec3 *trail_positions = (glm::vec3 *)calloc(sizeof(glm::vec3), trailmax); std::deque<TrailPoint> trail_points;
float *trail_angles = (float *)calloc(sizeof(float), trailmax); std::vector<TrailVertex> trail_vertices;
int trail_ix = 0; std::deque<TrailResetMarker> trail_reset_markers;
int trail_sz = 0; bool trail_dirty = update_trail(trail_points, player->get_pos(), 0.0f);
// do stuff for ghosts // do stuff for ghosts
// create program // create program
@@ -448,6 +496,10 @@ int main(int argc, char *argv[]) {
glm::vec3 ambient = glm::vec3(0.007f, 0.006f, 0.005f); glm::vec3 ambient = glm::vec3(0.007f, 0.006f, 0.005f);
glm::vec3 diffuse = glm::vec3(0.61f, 0.60f, 0.59f); glm::vec3 diffuse = glm::vec3(0.61f, 0.60f, 0.59f);
glm::vec3 specular = glm::vec3(0.11f, 0.10f, 0.09f); glm::vec3 specular = glm::vec3(0.11f, 0.10f, 0.09f);
// Persistent danger tint state. The target can change abruptly as ghosts move,
// so the displayed intensity is smoothed below to avoid camera flicker.
float danger_tint_intensity = 0.0f;
bool danger_tint_active = false;
float largecutoff = glm::cos(glm::radians(30.0f)); float largecutoff = glm::cos(glm::radians(30.0f));
float smallcutoff = glm::cos(glm::radians(7.5f)); float smallcutoff = glm::cos(glm::radians(7.5f));
@@ -484,15 +536,6 @@ int main(int argc, char *argv[]) {
time_sec = current_frame; time_sec = current_frame;
time_secI = current_frameI; time_secI = current_frameI;
num_frames = 1; num_frames = 1;
trail_positions[trail_ix].x = player_pos.x;
trail_positions[trail_ix].y = 6.5;
trail_positions[trail_ix].z = player_pos.z;
trail_angles[trail_ix] = yaw;
//printf("Recorded trail %d: (%f, %f, %f) %f\n", trail_ix, trail_positions[trail_ix].x, trail_positions[trail_ix].y, trail_positions[trail_ix].z, yaw);
trail_ix = (trail_ix + 1) % trailmax;
if (trail_sz < trailmax) {
trail_sz++;
}
} else { // otherwise increase number of frames } else { // otherwise increase number of frames
num_frames++; num_frames++;
} }
@@ -531,8 +574,25 @@ int main(int argc, char *argv[]) {
glm::vec3 light_front = player->get_light_front(); glm::vec3 light_front = player->get_light_front();
//printf("light_pos: (%f, %f, %f)\n", light_front.x, light_front.y, light_front.z); //printf("light_pos: (%f, %f, %f)\n", light_front.x, light_front.y, light_front.z);
// Sort ghosts by distance to player for optimization.
glm::vec3 player_pos = player->get_pos(); glm::vec3 player_pos = player->get_pos();
if (trail_config.enabled) {
trail_dirty = update_trail(trail_points, player_pos, current_frame) || trail_dirty;
prune_trail_reset_markers(trail_reset_markers, current_frame);
// Upload after sample/prune changes so the world trail VBO is ready
// when rendered later in this frame. The minimap reads the deque.
if (trail_dirty) {
rebuild_trail_mesh(trail_points, trail_vertices);
glBindBuffer(GL_ARRAY_BUFFER, trailVBO);
glBufferData(
GL_ARRAY_BUFFER,
std::max<size_t>(trail_vertices.size(), 1) * sizeof(TrailVertex),
trail_vertices.empty() ? NULL : trail_vertices.data(),
GL_DYNAMIC_DRAW
);
trail_dirty = false;
}
}
// Sort ghosts by distance to player for render-order optimization.
struct { struct {
bool operator()(const Ghost *a, const Ghost *b) const { bool operator()(const Ghost *a, const Ghost *b) const {
glm::vec3 a_diff, b_diff; glm::vec3 a_diff, b_diff;
@@ -547,21 +607,44 @@ int main(int argc, char *argv[]) {
// Only the closest 20% need perfect ordering for blending, so partial_sort is cheaper than full sort. // Only the closest 20% need perfect ordering for blending, so partial_sort is cheaper than full sort.
std::partial_sort(ghosts.begin(), ghosts.begin() + ghosts.size() / 5, ghosts.end(), ghost_compare); std::partial_sort(ghosts.begin(), ghosts.begin() + ghosts.size() / 5, ghosts.end(), ghost_compare);
// Calculate nearest ghost distance (first ghost is now closest) // Drive the danger tint from the actual nearest ghost. Use X/Z distance
float nearest_ghost_distance = glm::length(ghosts[0]->get_pos() - player_pos); // so vertical ghost bobbing cannot push the warning in and out.
float nearest_ghost_distance = distance_xz(ghosts[0]->get_pos(), player_pos);
for (size_t i = 1; i < ghosts.size(); i++) {
float ghost_distance = distance_xz(ghosts[i]->get_pos(), player_pos);
if (ghost_distance < nearest_ghost_distance) {
nearest_ghost_distance = ghost_distance;
}
}
// Interpolate flashlight color based on nearest ghost distance // Interpolate flashlight color based on nearest ghost distance. The
// active band has hysteresis so tiny distance changes at the edge do
// not flicker the tint on/off.
// At 30.0+ units: (0.61, 0.60, 0.59) - normal color // At 30.0+ units: (0.61, 0.60, 0.59) - normal color
// At 10.0 units: (0.99, 0.60, 0.59) - red shift // At 10.0 units: (0.99, 0.60, 0.59) - red shift
if (nearest_ghost_distance <= 30.0f) {
danger_tint_active = true;
} else if (nearest_ghost_distance >= 35.0f) {
danger_tint_active = false;
}
float color_factor = 1.0f; float color_factor = 1.0f;
if (nearest_ghost_distance < 30.0f) { if (danger_tint_active) {
color_factor = (nearest_ghost_distance - 10.0f) / (30.0f - 10.0f); color_factor = (nearest_ghost_distance - 10.0f) / (30.0f - 10.0f);
color_factor = glm::clamp(color_factor, 0.0f, 1.0f); color_factor = glm::clamp(color_factor, 0.0f, 1.0f);
} }
float target_danger_intensity = 1.0f - color_factor;
if (danger_tint_active) {
target_danger_intensity = std::max(target_danger_intensity, 0.35f);
}
// Attack quickly when danger rises, release more slowly when it falls.
float tint_response = target_danger_intensity > danger_tint_intensity ? 16.0f : 4.0f;
float tint_blend = 1.0f - exp(-tint_response * timed);
danger_tint_intensity = glm::mix(danger_tint_intensity, target_danger_intensity, tint_blend);
glm::vec3 normal_color = glm::vec3(0.61f, 0.60f, 0.59f); glm::vec3 normal_color = glm::vec3(0.61f, 0.60f, 0.59f);
glm::vec3 danger_color = glm::vec3(0.99f, 0.60f, 0.59f); glm::vec3 danger_color = glm::vec3(0.99f, 0.60f, 0.59f);
diffuse = glm::mix(danger_color, normal_color, color_factor); diffuse = glm::mix(normal_color, danger_color, danger_tint_intensity);
// fragment requirements // fragment requirements
set_uniform(wall_program, viewposC, camera_pos); set_uniform(wall_program, viewposC, camera_pos);
@@ -599,31 +682,6 @@ int main(int argc, char *argv[]) {
glBindVertexArray(floorVAO); glBindVertexArray(floorVAO);
glDrawArrays(GL_TRIANGLES, 0, 6 * 6 * 1); glDrawArrays(GL_TRIANGLES, 0, 6 * 6 * 1);
glUseProgram(trail_program);
set_uniform(trail_program, projectionC, projection);
set_uniform(trail_program, viewC, view);
glm::vec3 trail_color = glm::vec3(1.0f, 0.0f, 0.0f);
set_uniform(trail_program, objectcolorC, trail_color);
float breadcrumb_draw_limit_sq = minimap_config.breadcrumb_max_distance > 0.0f
? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance
: -1.0f;
for (int i = 0; i < trail_sz; i++) {
if (breadcrumb_draw_limit_sq > 0.0f) {
glm::vec3 delta = trail_positions[i] - player_pos;
if (glm::dot(delta, delta) > breadcrumb_draw_limit_sq) {
continue;
}
}
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, trail_positions[i]);
model = glm::rotate(model, glm::radians(trail_angles[i]), glm::vec3(0.0f, -1.0f, 0.0f));
set_uniform(trail_program, modelC, model);
glBindVertexArray(trailVAO);
glDrawArrays(GL_TRIANGLES, 0, 3 * 3 * 3);
}
glUseProgram(ghost_program); glUseProgram(ghost_program);
glActiveTexture(GL_TEXTURE0); glActiveTexture(GL_TEXTURE0);
@@ -631,6 +689,7 @@ int main(int argc, char *argv[]) {
set_uniform(ghost_program, projectionC, projection); set_uniform(ghost_program, projectionC, projection);
set_uniform(ghost_program, viewC, view); set_uniform(ghost_program, viewC, view);
bool player_reset_this_frame = false;
for (int i = ghosts.size()/5; i >= 0; i--) { for (int i = ghosts.size()/5; i >= 0; i--) {
glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos); glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos);
ghosts[i]->apply_movement(current_frame, timed); ghosts[i]->apply_movement(current_frame, timed);
@@ -639,8 +698,18 @@ int main(int argc, char *argv[]) {
glm::vec3 ghost_pos = ghosts[i]->get_pos(); glm::vec3 ghost_pos = ghosts[i]->get_pos();
glm::vec3 player_pos = player->get_pos(); glm::vec3 player_pos = player->get_pos();
float distance = glm::length(ghost_pos - player_pos); float distance = glm::length(ghost_pos - player_pos);
if (distance <= ghost_reset_distance) { if (!player_reset_this_frame && distance <= ghost_reset_distance) {
glm::vec3 reset_pos = player->get_pos();
float reset_yaw = player->get_yaw();
if (trail_config.enabled) {
trail_dirty = end_trail_segment(trail_points, reset_pos, current_frame) || trail_dirty;
add_trail_reset_marker(trail_reset_markers, reset_pos, reset_yaw, current_frame);
}
player->reset_position(start_position, start_yaw); player->reset_position(start_position, start_yaw);
if (trail_config.enabled) {
trail_dirty = begin_trail_segment(trail_points, start_position, current_frame) || trail_dirty;
}
player_reset_this_frame = true;
} }
// Nudge ghosts away from the spawn area so the player gets a breather. // Nudge ghosts away from the spawn area so the player gets a breather.
@@ -654,8 +723,48 @@ int main(int argc, char *argv[]) {
glDrawArrays(GL_TRIANGLES, 0, (3 + 3 + 2) * 6); glDrawArrays(GL_TRIANGLES, 0, (3 + 3 + 2) * 6);
} }
if (trail_config.enabled) {
// Ghost resets can split the trail after the initial upload above.
// Rebuild here before drawing so reset markers, world trail, and
// minimap all reflect the same reset on the current frame.
if (trail_dirty) {
rebuild_trail_mesh(trail_points, trail_vertices);
glBindBuffer(GL_ARRAY_BUFFER, trailVBO);
glBufferData(
GL_ARRAY_BUFFER,
std::max<size_t>(trail_vertices.size(), 1) * sizeof(TrailVertex),
trail_vertices.empty() ? NULL : trail_vertices.data(),
GL_DYNAMIC_DRAW
);
trail_dirty = false;
}
if (!trail_vertices.empty()) {
glUseProgram(trail_program);
set_uniform(trail_program, projectionC, projection);
set_uniform(trail_program, viewC, view);
set_uniform(trail_program, modelC, glm::mat4(1.0f));
set_uniform(trail_program, "currentTime", current_frame);
set_uniform(trail_program, "maxAge", std::max(0.001f, trail_config.lifetime_minutes * 60.0f));
set_uniform(trail_program, "recentColor", trail_config.recent_color);
set_uniform(trail_program, "oldColor", trail_config.old_color);
glBindVertexArray(trailVAO);
glDrawArrays(GL_TRIANGLES, 0, trail_vertices.size());
}
if (!trail_reset_markers.empty()) {
glUseProgram(ghost_program);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, ghost_texture);
set_uniform(ghost_program, projectionC, projection);
set_uniform(ghost_program, viewC, view);
render_trail_reset_markers(ghost_program, ghostVAO, trail_reset_markers);
}
}
if (minimap_config.enabled) { if (minimap_config.enabled) {
render_minimap(player->get_pos(), player->get_yaw(), trail_positions, trail_sz, ghosts); render_minimap(player->get_pos(), player->get_yaw(), trail_points, ghosts);
} }
if (FPS != -1) { if (FPS != -1) {
@@ -680,6 +789,10 @@ int main(int argc, char *argv[]) {
glDeleteVertexArrays(1, &floorVAO); glDeleteVertexArrays(1, &floorVAO);
glDeleteBuffers(1, &floorVBO); glDeleteBuffers(1, &floorVBO);
glDeleteVertexArrays(1, &trailVAO);
glDeleteBuffers(1, &trailVBO);
glDeleteProgram(trail_program);
if (minimap_config.enabled) { if (minimap_config.enabled) {
glDeleteVertexArrays(1, &minimapVAO); glDeleteVertexArrays(1, &minimapVAO);
glDeleteBuffers(1, &minimapVBO); glDeleteBuffers(1, &minimapVBO);
@@ -702,7 +815,223 @@ void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) {
player->scroll_callback(window, xoffset, yoffset); player->scroll_callback(window, xoffset, yoffset);
} }
void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::vec3 *trail_positions, int trail_sz, const std::vector<Ghost *> &ghosts) { float distance_xz(const glm::vec3 &a, const glm::vec3 &b) {
float dx = a.x - b.x;
float dz = a.z - b.z;
return sqrt(dx * dx + dz * dz);
}
bool is_redundant_trail_point(const TrailPoint &a, const TrailPoint &b, const TrailPoint &c) {
if (trail_config.simplify_epsilon <= 0.0f) {
return false;
}
glm::vec2 ab(b.position.x - a.position.x, b.position.z - a.position.z);
glm::vec2 ac(c.position.x - a.position.x, c.position.z - a.position.z);
float ac_len_sq = glm::dot(ac, ac);
if (ac_len_sq <= 0.0001f) {
return false;
}
float t = glm::dot(ab, ac) / ac_len_sq;
if (t <= 0.0f || t >= 1.0f) {
return false;
}
glm::vec2 closest = ac * t;
return glm::length(ab - closest) <= trail_config.simplify_epsilon;
}
bool update_trail(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time) {
if (!trail_config.enabled) {
bool had_points = !trail_points.empty();
trail_points.clear();
return had_points;
}
bool dirty = false;
// Keep a time window instead of a fixed sample count so trail length is
// stable across different movement speeds and frame rates.
float max_age = std::max(0.001f, trail_config.lifetime_minutes * 60.0f);
while (!trail_points.empty() && current_time - trail_points.front().timestamp > max_age) {
trail_points.pop_front();
dirty = true;
}
glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z);
float min_spacing = std::max(0.001f, trail_config.sample_spacing);
if (trail_points.empty() || distance_xz(sample_pos, trail_points.back().position) >= min_spacing) {
TrailPoint new_point = {sample_pos, current_time, trail_points.empty()};
if (trail_points.size() >= 2 && !trail_points.back().starts_segment) {
TrailPoint previous = trail_points.back();
TrailPoint before_previous = trail_points[trail_points.size() - 2];
if (!previous.starts_segment && is_redundant_trail_point(before_previous, previous, new_point)) {
trail_points.pop_back();
}
}
trail_points.push_back(new_point);
dirty = true;
}
return dirty;
}
bool end_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time) {
glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z);
if (trail_points.empty()) {
trail_points.push_back({sample_pos, current_time, true});
return true;
}
if (distance_xz(sample_pos, trail_points.back().position) <= 0.0001f) {
// If the last regular sample is already at the reset location, refresh
// its age rather than adding a zero-length segment.
trail_points.back().position = sample_pos;
trail_points.back().timestamp = current_time;
return true;
}
trail_points.push_back({sample_pos, current_time, false});
return true;
}
bool begin_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time) {
glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z);
if (!trail_points.empty()
&& trail_points.back().starts_segment
&& distance_xz(sample_pos, trail_points.back().position) <= 0.0001f) {
trail_points.back().timestamp = current_time;
return true;
}
TrailPoint new_point = {sample_pos, current_time, true};
trail_points.push_back(new_point);
return true;
}
void add_trail_reset_marker(std::deque<TrailResetMarker> &reset_markers, const glm::vec3 &player_pos, float player_yaw, float current_time) {
glm::vec3 marker_pos(player_pos.x, trail_config.y_position + 0.002f, player_pos.z);
reset_markers.push_back({marker_pos, player_yaw, current_time});
}
bool prune_trail_reset_markers(std::deque<TrailResetMarker> &reset_markers, float current_time) {
bool dirty = false;
float max_age = std::max(0.001f, trail_config.lifetime_minutes * 60.0f);
while (!reset_markers.empty() && current_time - reset_markers.front().timestamp > max_age) {
reset_markers.pop_front();
dirty = true;
}
return dirty;
}
void render_trail_reset_markers(int ghost_program, unsigned int ghostVAO, const std::deque<TrailResetMarker> &reset_markers) {
if (trail_config.reset_marker_size <= 0.0f) {
return;
}
glBindVertexArray(ghostVAO);
for (const TrailResetMarker &marker : reset_markers) {
glm::mat4 marker_model = glm::mat4(1.0f);
marker_model = glm::translate(marker_model, marker.position);
marker_model = glm::rotate(marker_model, glm::radians(90.0f - marker.yaw), glm::vec3(0.0f, 1.0f, 0.0f));
marker_model = glm::rotate(marker_model, glm::radians(90.0f), glm::vec3(1.0f, 0.0f, 0.0f));
marker_model = glm::scale(marker_model, glm::vec3(trail_config.reset_marker_size, trail_config.reset_marker_size, 1.0f));
set_uniform(ghost_program, modelC, marker_model);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
}
glm::vec2 trail_segment_direction(const std::deque<TrailPoint> &trail_points, size_t from_ix, size_t to_ix) {
glm::vec3 delta = trail_points[to_ix].position - trail_points[from_ix].position;
glm::vec2 delta_xz(delta.x, delta.z);
float len = glm::length(delta_xz);
if (len <= 0.0001f) {
return glm::vec2(0.0f, 0.0f);
}
return delta_xz / len;
}
glm::vec3 trail_join_offset(const std::deque<TrailPoint> &trail_points, size_t point_ix, const glm::vec2 &fallback_normal, float half_width) {
bool has_prev = point_ix > 0 && !trail_points[point_ix].starts_segment;
bool has_next = point_ix + 1 < trail_points.size() && !trail_points[point_ix + 1].starts_segment;
if (!has_prev && !has_next) {
return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width);
}
if (!has_prev || !has_next) {
glm::vec2 dir = has_prev
? trail_segment_direction(trail_points, point_ix - 1, point_ix)
: trail_segment_direction(trail_points, point_ix, point_ix + 1);
if (glm::length(dir) <= 0.0001f) {
return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width);
}
glm::vec2 normal(-dir.y, dir.x);
return glm::vec3(normal.x * half_width, 0.0f, normal.y * half_width);
}
glm::vec2 prev_dir = trail_segment_direction(trail_points, point_ix - 1, point_ix);
glm::vec2 next_dir = trail_segment_direction(trail_points, point_ix, point_ix + 1);
if (glm::length(prev_dir) <= 0.0001f || glm::length(next_dir) <= 0.0001f) {
return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width);
}
glm::vec2 prev_normal(-prev_dir.y, prev_dir.x);
glm::vec2 next_normal(-next_dir.y, next_dir.x);
glm::vec2 miter = prev_normal + next_normal;
float miter_len = glm::length(miter);
if (miter_len <= 0.0001f) {
return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width);
}
miter /= miter_len;
float denom = glm::dot(miter, fallback_normal);
if (denom <= 0.1f) {
return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width);
}
// Clamp miters so tight turns do not produce long spikes in the ribbon.
float miter_scale = std::min(half_width / denom, half_width * 2.5f);
return glm::vec3(miter.x * miter_scale, 0.0f, miter.y * miter_scale);
}
void rebuild_trail_mesh(const std::deque<TrailPoint> &trail_points, std::vector<TrailVertex> &trail_vertices) {
trail_vertices.clear();
if (trail_points.size() < 2 || trail_config.width <= 0.0f) {
return;
}
trail_vertices.reserve((trail_points.size() - 1) * 6);
float half_width = trail_config.width * 0.5f;
for (size_t i = 1; i < trail_points.size(); i++) {
const TrailPoint &p0 = trail_points[i - 1];
const TrailPoint &p1 = trail_points[i];
if (p1.starts_segment) {
continue;
}
glm::vec2 dir = trail_segment_direction(trail_points, i - 1, i);
if (glm::length(dir) <= 0.0001f) {
continue;
}
glm::vec2 normal(-dir.y, dir.x);
glm::vec3 start_offset = trail_join_offset(trail_points, i - 1, normal, half_width);
glm::vec3 end_offset = trail_join_offset(trail_points, i, normal, half_width);
TrailVertex v0 = {p0.position + start_offset, p0.timestamp};
TrailVertex v1 = {p0.position - start_offset, p0.timestamp};
TrailVertex v2 = {p1.position + end_offset, p1.timestamp};
TrailVertex v3 = {p1.position - end_offset, p1.timestamp};
trail_vertices.push_back(v0);
trail_vertices.push_back(v1);
trail_vertices.push_back(v2);
trail_vertices.push_back(v2);
trail_vertices.push_back(v1);
trail_vertices.push_back(v3);
}
}
void render_minimap(const glm::vec3 &player_pos, float player_yaw, const std::deque<TrailPoint> &trail_points, const std::vector<Ghost *> &ghosts) {
// 2D overlay renders last so sorting/blending for 3D content is unaffected. // 2D overlay renders last so sorting/blending for 3D content is unaffected.
if (!minimap_config.enabled || minimap_program < 0) { if (!minimap_config.enabled || minimap_program < 0) {
return; return;
@@ -761,35 +1090,62 @@ void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::ve
} }
if (!line_vertices.empty()) { if (!line_vertices.empty()) {
glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); glBindBuffer(GL_ARRAY_BUFFER, minimapVBO);
glBufferSubData(GL_ARRAY_BUFFER, 0, line_vertices.size() * sizeof(float), line_vertices.data()); glBufferData(GL_ARRAY_BUFFER, line_vertices.size() * sizeof(float), line_vertices.data(), GL_DYNAMIC_DRAW);
set_uniform(minimap_program, "inColor", minimap_config.wall_color); set_uniform(minimap_program, "inColor", minimap_config.wall_color);
glDrawArrays(GL_LINES, 0, line_vertices.size() / 2); glDrawArrays(GL_LINES, 0, line_vertices.size() / 2);
} }
// Breadcrumbs mark recently recorded player positions. // The minimap mirrors the world trail as a real screen-space stroke. The
if (trail_positions != nullptr && trail_sz > 0 && minimap_config.breadcrumb_radius > 0.0f) { // old breadcrumb radius now acts as stroke half-width in pixels.
const int circle_segments = 12; if (trail_points.size() >= 2 && minimap_config.breadcrumb_radius > 0.0f) {
const float angle_step = glm::two_pi<float>() / static_cast<float>(circle_segments); static std::vector<float> trail_line_vertices;
float radius = minimap_config.breadcrumb_radius; trail_line_vertices.clear();
float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance : -1.0f; trail_line_vertices.reserve((trail_points.size() - 1) * 12);
std::vector<float> circle_vertices((circle_segments + 2) * 2); float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f
for (int i = 0; i < trail_sz; i++) { ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance
glm::vec2 rel = glm::vec2(trail_positions[i].x, trail_positions[i].z) - player_flat; : -1.0f;
if (max_dist_sq > 0.0f && glm::dot(rel, rel) > max_dist_sq) { float half_width = minimap_config.breadcrumb_radius;
for (size_t i = 1; i < trail_points.size(); i++) {
if (trail_points[i].starts_segment) {
continue; continue;
} }
glm::vec2 center = map_center + rel * scale; glm::vec2 rel0 = glm::vec2(trail_points[i - 1].position.x, trail_points[i - 1].position.z) - player_flat;
circle_vertices[0] = center.x; glm::vec2 rel1 = glm::vec2(trail_points[i].position.x, trail_points[i].position.z) - player_flat;
circle_vertices[1] = center.y; if (max_dist_sq > 0.0f && glm::dot(rel0, rel0) > max_dist_sq && glm::dot(rel1, rel1) > max_dist_sq) {
for (int seg = 0; seg <= circle_segments; seg++) { continue;
float angle = seg * angle_step;
circle_vertices[(seg + 1) * 2 + 0] = center.x + cos(angle) * radius;
circle_vertices[(seg + 1) * 2 + 1] = center.y + sin(angle) * radius;
} }
glm::vec2 p0 = map_center + rel0 * scale;
glm::vec2 p1 = map_center + rel1 * scale;
glm::vec2 segment = p1 - p0;
float segment_len = glm::length(segment);
if (segment_len <= 0.0001f) {
continue;
}
glm::vec2 normal = glm::vec2(-segment.y, segment.x) / segment_len * half_width;
glm::vec2 p0_left = p0 + normal;
glm::vec2 p0_right = p0 - normal;
glm::vec2 p1_left = p1 + normal;
glm::vec2 p1_right = p1 - normal;
trail_line_vertices.push_back(p0_left.x);
trail_line_vertices.push_back(p0_left.y);
trail_line_vertices.push_back(p0_right.x);
trail_line_vertices.push_back(p0_right.y);
trail_line_vertices.push_back(p1_left.x);
trail_line_vertices.push_back(p1_left.y);
trail_line_vertices.push_back(p1_left.x);
trail_line_vertices.push_back(p1_left.y);
trail_line_vertices.push_back(p0_right.x);
trail_line_vertices.push_back(p0_right.y);
trail_line_vertices.push_back(p1_right.x);
trail_line_vertices.push_back(p1_right.y);
}
if (!trail_line_vertices.empty()) {
glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); glBindBuffer(GL_ARRAY_BUFFER, minimapVBO);
glBufferSubData(GL_ARRAY_BUFFER, 0, circle_vertices.size() * sizeof(float), circle_vertices.data()); glBufferData(GL_ARRAY_BUFFER, trail_line_vertices.size() * sizeof(float), trail_line_vertices.data(), GL_DYNAMIC_DRAW);
set_uniform(minimap_program, "inColor", minimap_config.breadcrumb_color); set_uniform(minimap_program, "inColor", minimap_config.breadcrumb_color);
glDrawArrays(GL_TRIANGLE_FAN, 0, circle_vertices.size() / 2); glDrawArrays(GL_TRIANGLES, 0, trail_line_vertices.size() / 2);
} }
} }
@@ -808,7 +1164,7 @@ void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::ve
ghost_vertices[(seg + 1) * 2 + 1] = center.y + sin(angle) * radius; ghost_vertices[(seg + 1) * 2 + 1] = center.y + sin(angle) * radius;
} }
glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); glBindBuffer(GL_ARRAY_BUFFER, minimapVBO);
glBufferSubData(GL_ARRAY_BUFFER, 0, ghost_vertices.size() * sizeof(float), ghost_vertices.data()); glBufferData(GL_ARRAY_BUFFER, ghost_vertices.size() * sizeof(float), ghost_vertices.data(), GL_DYNAMIC_DRAW);
set_uniform(minimap_program, "inColor", color); set_uniform(minimap_program, "inColor", color);
glDrawArrays(GL_TRIANGLE_FAN, 0, ghost_vertices.size() / 2); glDrawArrays(GL_TRIANGLE_FAN, 0, ghost_vertices.size() / 2);
}; };

View File

@@ -27,5 +27,18 @@
"minimap_ghost_border_g": 0.2, "minimap_ghost_border_g": 0.2,
"minimap_ghost_border_b": 0.4, "minimap_ghost_border_b": 0.4,
"minimap_ghost_radius": 4.0, "minimap_ghost_radius": 4.0,
"breadcrumb_max_distance": 200.0 "breadcrumb_max_distance": 200.0,
"trail_enabled": 1,
"trail_lifetime_minutes": 3.0,
"trail_sample_spacing": 1.0,
"trail_width": 0.25,
"trail_y_position": 10.0,
"trail_simplify_epsilon": 0.15,
"trail_reset_marker_size": 2.0,
"trail_recent_r": 1.0,
"trail_recent_g": 0.0,
"trail_recent_b": 0.0,
"trail_old_r": 0.35,
"trail_old_g": 0.18,
"trail_old_b": 0.05
} }

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Billboarded ghost vertex shader: passes world pos/normal and flips UV.y for texture atlases. // Billboarded ghost vertex shader: passes world pos/normal and flips UV.y for texture atlases.
layout (location = 0) in vec3 aPos; layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal; layout (location = 1) in vec3 aNormal;

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Solid-color fragment shader for minimap overlay primitives. // Solid-color fragment shader for minimap overlay primitives.
out vec4 FragColor; out vec4 FragColor;

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Vertex shader for minimap overlay geometry (lines/quads) in screen space. // Vertex shader for minimap overlay geometry (lines/quads) in screen space.
layout (location = 0) in vec2 aPos; layout (location = 0) in vec2 aPos;

View File

@@ -14,7 +14,7 @@ Player::Player(glm::vec3 startpos, float startyaw) {
first_frame = false; first_frame = false;
light_xpersist = 0.0f; light_xpersist = 0.0f;
light_ypersist = 0.0f; light_ypersist = 0.0f;
armlength = 0.5f; armlength = 0.0f;
camera_offset = glm::vec3(0.0f, 2.5f, 0.0f); camera_offset = glm::vec3(0.0f, 2.5f, 0.0f);
camera_up = glm::vec3(0.0f, 1.0f, 0.0f); camera_up = glm::vec3(0.0f, 1.0f, 0.0f);
fov = 60.0f; fov = 60.0f;

View File

@@ -9,7 +9,7 @@
int create_shader(const char *filename, int shadertype) { int create_shader(const char *filename, int shadertype) {
FILE* fp = fopen(filename, "r"); FILE* fp = fopen(filename, "r");
if (!fp) { if (!fp) {
printf("failed to open vertex shader!\n"); printf("failed to open shader file %s!\n", filename);
return -1; return -1;
} }
fseek(fp, 0L, SEEK_END); fseek(fp, 0L, SEEK_END);
@@ -18,7 +18,7 @@ int create_shader(const char *filename, int shadertype) {
char *buffer = (char *)calloc(sizeof(char), buffer_sz + 1); char *buffer = (char *)calloc(sizeof(char), buffer_sz + 1);
if (fread(buffer, buffer_sz, 1, fp) != 1) { if (fread(buffer, buffer_sz, 1, fp) != 1) {
printf("failed to read vertex shader!\n"); printf("failed to read shader file %s!\n", filename);
return -1; return -1;
} }
fclose(fp); fclose(fp);

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Samples single-channel glyph texture and applies RGB tint. // Samples single-channel glyph texture and applies RGB tint.
in vec2 TexCoords; in vec2 TexCoords;
out vec4 color; out vec4 color;

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// HUD text vertex shader: projects screen-space quad verts and forwards glyph UVs. // HUD text vertex shader: projects screen-space quad verts and forwards glyph UVs.
layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex> layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex>
out vec2 TexCoords; out vec2 TexCoords;
@@ -7,6 +7,6 @@ uniform mat4 projection;
void main() void main()
{ {
gl_Position = projection * vec4(vertex.xy, 0.0f, 1.0); gl_Position = projection * vec4(vertex.xy, 0.0, 1.0);
TexCoords = vertex.zw; TexCoords = vertex.zw;
} }

View File

@@ -1,12 +1,17 @@
#version 460 core #version 330 core
// Colors the trail geometry with a solid uniform tint. // Colors the trail geometry by age: newest samples are red and old samples
// fade toward brown before they expire from the CPU-side trail window.
out vec4 FragColor; out vec4 FragColor;
in vec3 FragPos; in float TrailTimestamp;
uniform vec3 objectcolor; uniform float currentTime;
uniform float maxAge;
uniform vec3 recentColor;
uniform vec3 oldColor;
void main() void main()
{ {
FragColor = vec4(objectcolor, 1.0); float ageRatio = clamp((currentTime - TrailTimestamp) / maxAge, 0.0, 1.0);
FragColor = vec4(mix(recentColor, oldColor, ageRatio), 1.0);
} }

View File

@@ -1,8 +1,10 @@
#version 460 core #version 330 core
// Simple vertex shader for the player's breadcrumb trail triangles. // Dynamic ribbon trail vertex shader. Timestamp is used by the fragment shader
// to fade recent path segments from red toward brown as they age.
layout (location = 0) in vec3 aPos; layout (location = 0) in vec3 aPos;
layout (location = 1) in float aTimestamp;
out vec3 FragPos; out float TrailTimestamp;
uniform mat4 model; uniform mat4 model;
uniform mat4 view; uniform mat4 view;
@@ -10,7 +12,7 @@ uniform mat4 projection;
void main() void main()
{ {
FragPos = vec3(model * vec4(aPos, 1.0)); TrailTimestamp = aTimestamp;
gl_Position = projection * view * vec4(FragPos, 1.0); gl_Position = projection * view * model * vec4(aPos, 1.0);
} }

View File

@@ -1,4 +1,4 @@
#version 460 core #version 330 core
// Basic vertex shader for world geometry (walls/floor). Transforms positions/normals into view space. // Basic vertex shader for world geometry (walls/floor). Transforms positions/normals into view space.
layout (location = 0) in vec3 aPos; layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal; layout (location = 1) in vec3 aNormal;