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
## 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
- `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
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
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
- 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.
- 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.
- 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.
- 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, 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, 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_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). |
| `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_border_[rgb]` | float | `0.2 / 0.2 / 0.4` | Ghost dot border color. |
| `minimap_ghost_radius` | float | `4.0` | Ghost indicator radius in pixels. |
@@ -54,6 +55,7 @@ Example:
"minimap_breadcrumb_b": 0.3,
"minimap_breadcrumb_radius": 3.0,
"breadcrumb_max_distance": 200.0,
"trail_y_position": 0.06,
"minimap_ghost_fill_r": 0.9,
"minimap_ghost_fill_g": 0.9,
"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.
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%.
out vec4 FragColor;

View File

@@ -14,6 +14,7 @@
#include <iostream>
#include <filesystem>
#include <vector>
#include <deque>
#include <math.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 scroll_callback(GLFWwindow* window, double xoffset, double 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;
@@ -76,7 +76,52 @@ struct MinimapBounds {
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;
TrailConfig trail_config;
std::vector<MinimapSegment> minimap_segments;
MinimapBounds minimap_bounds;
unsigned int minimapVAO = 0;
@@ -87,7 +132,6 @@ const char *projectionC = "projection";
const char *viewC = "view";
const char *modelC = "model";
const char *objectcolorC = "objectcolor";
const char *viewposC = "viewPos";
const char *shininessC = "material.shininess";
const char *colorC = "material.color";
@@ -102,8 +146,6 @@ const char *specularC = "light.specular";
int num_walls;
float *wall_vertices;
int trailmax = 500;
int main(int argc, char *argv[]) {
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_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);
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.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);
@@ -269,6 +325,9 @@ int main(int argc, char *argv[]) {
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_CULL_FACE);
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?
int floor_program = create_shader_program("vertexshader.glsl", "fragmentshader.glsl");
if (wall_program < 0) {
if (floor_program < 0) {
glfwTerminate();
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)));
glEnableVertexAttribArray(1);
// do stuff for trail
float trail_vertices[] = {
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,
};
// The trail is a dynamic ribbon mesh. Each vertex carries the timestamp of
// its source path sample so the shader can fade red-to-brown by age.
unsigned int trailVBO, trailVAO;
glGenVertexArrays(1, &trailVAO);
glGenBuffers(1, &trailVBO);
@@ -345,10 +391,12 @@ int main(int argc, char *argv[]) {
glBindVertexArray(trailVAO);
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);
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");
if (trail_program < 0) {
@@ -356,10 +404,10 @@ int main(int argc, char *argv[]) {
return -1;
}
glm::vec3 *trail_positions = (glm::vec3 *)calloc(sizeof(glm::vec3), trailmax);
float *trail_angles = (float *)calloc(sizeof(float), trailmax);
int trail_ix = 0;
int trail_sz = 0;
std::deque<TrailPoint> trail_points;
std::vector<TrailVertex> trail_vertices;
std::deque<TrailResetMarker> trail_reset_markers;
bool trail_dirty = update_trail(trail_points, player->get_pos(), 0.0f);
// do stuff for ghosts
// 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 diffuse = glm::vec3(0.61f, 0.60f, 0.59f);
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 smallcutoff = glm::cos(glm::radians(7.5f));
@@ -484,15 +536,6 @@ int main(int argc, char *argv[]) {
time_sec = current_frame;
time_secI = current_frameI;
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
num_frames++;
}
@@ -531,8 +574,25 @@ int main(int argc, char *argv[]) {
glm::vec3 light_front = player->get_light_front();
//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();
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 {
bool operator()(const Ghost *a, const Ghost *b) const {
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.
std::partial_sort(ghosts.begin(), ghosts.begin() + ghosts.size() / 5, ghosts.end(), ghost_compare);
// Calculate nearest ghost distance (first ghost is now closest)
float nearest_ghost_distance = glm::length(ghosts[0]->get_pos() - player_pos);
// Drive the danger tint from the actual nearest ghost. Use X/Z distance
// 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 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;
if (nearest_ghost_distance < 30.0f) {
if (danger_tint_active) {
color_factor = (nearest_ghost_distance - 10.0f) / (30.0f - 10.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 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
set_uniform(wall_program, viewposC, camera_pos);
@@ -599,31 +682,6 @@ int main(int argc, char *argv[]) {
glBindVertexArray(floorVAO);
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);
glActiveTexture(GL_TEXTURE0);
@@ -631,6 +689,7 @@ int main(int argc, char *argv[]) {
set_uniform(ghost_program, projectionC, projection);
set_uniform(ghost_program, viewC, view);
bool player_reset_this_frame = false;
for (int i = ghosts.size()/5; i >= 0; i--) {
glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos);
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 player_pos = player->get_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);
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.
@@ -654,8 +723,48 @@ int main(int argc, char *argv[]) {
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) {
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) {
@@ -680,6 +789,10 @@ int main(int argc, char *argv[]) {
glDeleteVertexArrays(1, &floorVAO);
glDeleteBuffers(1, &floorVBO);
glDeleteVertexArrays(1, &trailVAO);
glDeleteBuffers(1, &trailVBO);
glDeleteProgram(trail_program);
if (minimap_config.enabled) {
glDeleteVertexArrays(1, &minimapVAO);
glDeleteBuffers(1, &minimapVBO);
@@ -702,7 +815,223 @@ void scroll_callback(GLFWwindow* window, double xoffset, double 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.
if (!minimap_config.enabled || minimap_program < 0) {
return;
@@ -761,35 +1090,62 @@ void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::ve
}
if (!line_vertices.empty()) {
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);
glDrawArrays(GL_LINES, 0, line_vertices.size() / 2);
}
// Breadcrumbs mark recently recorded player positions.
if (trail_positions != nullptr && trail_sz > 0 && minimap_config.breadcrumb_radius > 0.0f) {
const int circle_segments = 12;
const float angle_step = glm::two_pi<float>() / static_cast<float>(circle_segments);
float radius = minimap_config.breadcrumb_radius;
float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance : -1.0f;
std::vector<float> circle_vertices((circle_segments + 2) * 2);
for (int i = 0; i < trail_sz; i++) {
glm::vec2 rel = glm::vec2(trail_positions[i].x, trail_positions[i].z) - player_flat;
if (max_dist_sq > 0.0f && glm::dot(rel, rel) > max_dist_sq) {
// The minimap mirrors the world trail as a real screen-space stroke. The
// old breadcrumb radius now acts as stroke half-width in pixels.
if (trail_points.size() >= 2 && minimap_config.breadcrumb_radius > 0.0f) {
static std::vector<float> trail_line_vertices;
trail_line_vertices.clear();
trail_line_vertices.reserve((trail_points.size() - 1) * 12);
float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f
? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance
: -1.0f;
float half_width = minimap_config.breadcrumb_radius;
for (size_t i = 1; i < trail_points.size(); i++) {
if (trail_points[i].starts_segment) {
continue;
}
glm::vec2 center = map_center + rel * scale;
circle_vertices[0] = center.x;
circle_vertices[1] = center.y;
for (int seg = 0; seg <= circle_segments; seg++) {
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 rel0 = glm::vec2(trail_points[i - 1].position.x, trail_points[i - 1].position.z) - player_flat;
glm::vec2 rel1 = glm::vec2(trail_points[i].position.x, trail_points[i].position.z) - player_flat;
if (max_dist_sq > 0.0f && glm::dot(rel0, rel0) > max_dist_sq && glm::dot(rel1, rel1) > max_dist_sq) {
continue;
}
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);
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);
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;
}
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);
glDrawArrays(GL_TRIANGLE_FAN, 0, ghost_vertices.size() / 2);
};

View File

@@ -27,5 +27,18 @@
"minimap_ghost_border_g": 0.2,
"minimap_ghost_border_b": 0.4,
"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.
layout (location = 0) in vec3 aPos;
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.
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.
layout (location = 0) in vec2 aPos;

View File

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

View File

@@ -9,7 +9,7 @@
int create_shader(const char *filename, int shadertype) {
FILE* fp = fopen(filename, "r");
if (!fp) {
printf("failed to open vertex shader!\n");
printf("failed to open shader file %s!\n", filename);
return -1;
}
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);
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;
}
fclose(fp);

View File

@@ -1,4 +1,4 @@
#version 460 core
#version 330 core
// Samples single-channel glyph texture and applies RGB tint.
in vec2 TexCoords;
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.
layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex>
out vec2 TexCoords;
@@ -7,6 +7,6 @@ uniform mat4 projection;
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;
}

View File

@@ -1,12 +1,17 @@
#version 460 core
// Colors the trail geometry with a solid uniform tint.
#version 330 core
// 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;
in vec3 FragPos;
in float TrailTimestamp;
uniform vec3 objectcolor;
uniform float currentTime;
uniform float maxAge;
uniform vec3 recentColor;
uniform vec3 oldColor;
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
// Simple vertex shader for the player's breadcrumb trail triangles.
#version 330 core
// 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 = 1) in float aTimestamp;
out vec3 FragPos;
out float TrailTimestamp;
uniform mat4 model;
uniform mat4 view;
@@ -10,7 +12,7 @@ uniform mat4 projection;
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.
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;