Add black hole and stuff

master
John Montagu, the 4th Earl of Sandvich 2026-03-16 13:03:53 -07:00
parent fb506f6f05
commit bd622d1873
Signed by: sandvich
GPG Key ID: 9A39BE37E602B22D
17 changed files with 244 additions and 121 deletions

View File

@ -8,6 +8,7 @@
#include "components/LifetimeComponent.hpp"
#include "components/NullZoneComponent.hpp"
#include "components/PhysicsComponent.hpp"
#include "components/PlayerBlackHoleComponent.hpp"
#include "components/ProbeStateComponent.hpp"
#include "components/ProjectionComponent.hpp"
#include "components/RenderComponent.hpp"

View File

@ -34,13 +34,21 @@ inline void DrawMainMenu() {
"GRAVITY SURFING");
GuiLabel((Rectangle){WINDOW_WIDTH / 2 - 200, WINDOW_HEIGHT / 2, 400, 60},
"LEFT CLICK to turn on gravity well and attract probe\n"
"Avoid hazards and collect stars to keep your meter from draining\n"
"If your meter runs out, gravity well turns off and you lose control\n"
"until you collect another star\n"
"LEFT CLICK to place a black hole that lasts 1 second\n"
"Each black hole costs 10 meter and does not drain over time\n"
"Avoid hazards and collect stars to refill meter\n"
"Place as many black holes as your meter allows\n"
"Press SPACE or LEFT CLICK to start");
}
inline void DrawDeathStats(int stars) {
GuiLabel((Rectangle){WINDOW_WIDTH / 2 - 150, WINDOW_HEIGHT / 2 - 40, 300, 32}, "YOU DIED");
GuiLabel((Rectangle){WINDOW_WIDTH / 2 - 150, WINDOW_HEIGHT / 2 - 4, 300, 24},
TextFormat("Stars Collected: %d", stars));
GuiLabel((Rectangle){WINDOW_WIDTH / 2 - 150, WINDOW_HEIGHT / 2 + 28, 300, 20},
"Press ENTER or LEFT CLICK to retry");
}
//------------------------------------------------------------------------------------
// Controls Functions Definitions (local)
//------------------------------------------------------------------------------------

View File

@ -3,6 +3,7 @@
#include "Components.hpp"
#include "EnergyBarRaygui.hpp"
#include "raylib.h"
#include <algorithm>
std::shared_ptr<Entity> CreateProbe() {
@ -54,19 +55,8 @@ std::shared_ptr<Entity> CreateGravityWell() {
transform.y = 230.0f;
auto &well = e->AddComponent<GravityWellComponent>();
well.mass = static_cast<float>(1 << 22);
well.mass = 0.0f;
well.minDist = 28.0f;
well.followLerp = 12.0f;
auto &render = e->AddComponent<RenderComponent>();
render.draw = [e]() {
auto transform = e->GetComponent<TransformComponent>();
if (!transform) {
return;
}
DrawCircleLines(static_cast<int>(transform->get().x), static_cast<int>(transform->get().y),
18.0f, Color{86, 197, 255, 255});
};
return e;
}
@ -85,6 +75,8 @@ std::shared_ptr<Entity> CreateBlackHole(float x, float y) {
well.controlledByMouse = false;
well.alwaysActive = true;
// shader removed: previously used for black hole lensing
auto &collider = e->AddComponent<ColliderComponent>();
collider.radius = 18.0f;
@ -107,6 +99,52 @@ std::shared_ptr<Entity> CreateBlackHole(float x, float y) {
return e;
}
std::shared_ptr<Entity> CreatePlayerBlackHole(float worldX, float y, float scrollX, float ttl) {
auto e = std::make_shared<Entity>();
auto &transform = e->AddComponent<TransformComponent>();
transform.x = worldX - scrollX;
transform.y = y;
auto &scrollable = e->AddComponent<ScrollableComponent>();
scrollable.worldX = worldX;
auto &well = e->AddComponent<GravityWellComponent>();
well.mass = static_cast<float>(1 << 22);
well.minDist = 28.0f;
well.controlledByMouse = false;
well.alwaysActive = true;
// shader removed: previously used for black hole lensing
auto &lifetime = e->AddComponent<LifetimeComponent>();
lifetime.remaining = ttl;
// Mark this as a player-created black hole so it can be removed via right-click
e->AddComponent<PlayerBlackHoleComponent>();
auto &render = e->AddComponent<RenderComponent>();
render.draw = [e]() {
auto transform = e->GetComponent<TransformComponent>();
if (!transform) {
return;
}
auto lifetime = e->GetComponent<LifetimeComponent>();
if (!lifetime) {
return;
}
const int cx = static_cast<int>(transform->get().x);
const int cy = static_cast<int>(transform->get().y);
float t = 360 * lifetime->get().remaining / 1;
DrawCircleSector({(float)cx, (float)cy}, 28.0f, 0.0f, t, 32, Color{70, 95, 170, 25});
DrawCircleLines(cx, cy, 18.0f, Color{70, 95, 170, 240});
DrawCircleLines(cx, cy, 24.0f, Color{95, 120, 200, 120});
};
return e;
}
std::shared_ptr<Entity> CreateStar(float x, float y) {
auto e = std::make_shared<Entity>();
auto &t = e->AddComponent<TransformComponent>();
@ -115,7 +153,7 @@ std::shared_ptr<Entity> CreateStar(float x, float y) {
auto &scrollable = e->AddComponent<ScrollableComponent>();
scrollable.worldX = x;
auto &collider = e->AddComponent<ColliderComponent>();
collider.radius = 12.0f;
collider.radius = 20.0f;
e->AddComponent<CollectibleComponent>();
auto &render = e->AddComponent<RenderComponent>();
@ -126,9 +164,10 @@ std::shared_ptr<Entity> CreateStar(float x, float y) {
}
Vector2 center = {transform->get().x, transform->get().y};
Color STAR_COLOR = Color{255, 223, 86, 255};
Color GLOW_COLOR = Color{255, 223, 86, 120};
DrawCircleV(center, 12.0f, GLOW_COLOR);
DrawPoly(center, 3, 4.0f, transform->get().x, STAR_COLOR);
Color GLOW_COLOR = Color{255, 223, 86, 80};
DrawCircleV(center, 20.0f, GLOW_COLOR);
DrawPoly(center, 3, 10.0f, transform->get().x, STAR_COLOR);
DrawPoly(center, 3, 10.0f, transform->get().x + 180, STAR_COLOR);
};
return e;
}

View File

@ -7,6 +7,8 @@
std::shared_ptr<Entity> CreateProbe();
std::shared_ptr<Entity> CreateGravityWell();
std::shared_ptr<Entity> CreateBlackHole(float x, float y);
std::shared_ptr<Entity> CreatePlayerBlackHole(float worldX, float y, float scrollX,
float ttl = 1.0f);
std::shared_ptr<Entity> CreateStar(float x, float y);
std::shared_ptr<Entity> CreateAsteroid(float x, float y);
std::shared_ptr<Entity> CreateDebris(float x, float y, float vx, float vy, float ttl = 1.25f);

View File

@ -4,7 +4,6 @@
#include "components/GravityWellComponent.hpp"
#include "components/NullZoneComponent.hpp"
#include "components/PhysicsComponent.hpp"
#include "components/StatsComponent.hpp"
#include "components/TransformComponent.hpp"
#include <algorithm>
@ -17,33 +16,9 @@ void GravityReceiverComponent::Update(float dt) {
return;
}
if (!well && context->wellEntity) {
well = context->wellEntity;
}
if (!well) {
return;
}
auto myTransform = entity->GetComponent<TransformComponent>();
auto physics = entity->GetComponent<PhysicsComponent>();
auto wellTransform = well->GetComponent<TransformComponent>();
auto wellGravity = well->GetComponent<GravityWellComponent>();
if (!myTransform || !physics || !wellTransform || !wellGravity) {
return;
}
StatsComponent *statsPtr = nullptr;
if (context->statsEntity) {
auto stats = context->statsEntity->GetComponent<StatsComponent>();
if (stats) {
statsPtr = &stats->get();
}
}
if (!wellGravity->get().active) {
return;
}
if (statsPtr && statsPtr->value <= 0.0f) {
if (!myTransform || !physics) {
return;
}
@ -72,23 +47,35 @@ void GravityReceiverComponent::Update(float dt) {
return;
}
const float dx = wellTransform->get().x - myTransform->get().x;
const float dy = wellTransform->get().y - myTransform->get().y;
const float dist = std::sqrt(dx * dx + dy * dy);
if (dist <= 0.0001f) {
if (!context->entities) {
return;
}
const float clampedDist = std::max(dist, wellGravity->get().minDist);
const float force = wellGravity->get().mass / (clampedDist * clampedDist);
const float nx = dx / dist;
const float ny = dy / dist;
for (auto &other : *context->entities) {
if (!other || other.get() == entity) {
continue;
}
physics->get().vx += nx * force * dt;
physics->get().vy += ny * force * dt;
auto wellTransform = other->GetComponent<TransformComponent>();
auto wellGravity = other->GetComponent<GravityWellComponent>();
if (!wellTransform || !wellGravity || !wellGravity->get().active) {
continue;
}
if (statsPtr) {
statsPtr->Drain(statsPtr->drainRate * dt);
const float dx = wellTransform->get().x - myTransform->get().x;
const float dy = wellTransform->get().y - myTransform->get().y;
const float dist = std::sqrt(dx * dx + dy * dy);
if (dist <= 0.0001f) {
continue;
}
const float clampedDist = std::max(dist, wellGravity->get().minDist);
const float force = wellGravity->get().mass / (clampedDist * clampedDist);
const float nx = dx / dist;
const float ny = dy / dist;
physics->get().vx += nx * force * dt;
physics->get().vy += ny * force * dt;
}
}

View File

@ -3,15 +3,9 @@
#include "Component.hpp"
/**
* Applies gravity well force to the probe and drains meter while active.
* Applies gravity from active wells to the probe.
*/
struct GravityReceiverComponent : public Component {
/**
* The gravity well entity that this receiver is affected by. If null, the receiver will not
* apply any forces.
*/
Entity *well = nullptr;
/**
* Whether the probe is inside a void zone, which disables the well's gravity.
*/

View File

@ -1,32 +1,58 @@
#include "components/GravityWellComponent.hpp"
#include "Entities.hpp"
#include "Entity.hpp"
#include "components/TransformComponent.hpp"
#include "components/PlayerBlackHoleComponent.hpp"
#include "components/StatsComponent.hpp"
#include "raylib.h"
#include <algorithm>
void GravityWellComponent::Setup() {}
void GravityWellComponent::Update(float dt) {
auto transform = entity->GetComponent<TransformComponent>();
if (!transform) {
return;
}
void GravityWellComponent::Update(float) {
if (!controlledByMouse) {
active = alwaysActive;
return;
}
active = IsMouseButtonDown(MOUSE_BUTTON_LEFT);
active = false;
if (!context || !context->entities) {
return;
}
// Right click removes all player-created black holes
if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) {
for (auto &ent : *context->entities) {
if (!ent || ent->queuedForFree) {
continue;
}
auto marker = ent->GetComponent<PlayerBlackHoleComponent>();
if (marker) {
ent->QueueFree();
}
}
// Do not early return; allow left-click placement to still happen in the same frame
}
if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT) || !context->statsEntity) {
return;
}
auto stats = context->statsEntity->GetComponent<StatsComponent>();
if (!stats || stats->get().value < placementCost) {
return;
}
stats->get().Drain(placementCost);
const Vector2 mouse = GetMousePosition();
auto &t = transform->get();
const float blend = std::clamp(followLerp * dt, 0.0f, 1.0f);
t.x += (mouse.x - t.x) * blend;
t.y += (mouse.y - t.y) * blend;
const float worldX = context->scrollX + mouse.x;
auto blackHole = CreatePlayerBlackHole(worldX, mouse.y, context->scrollX, placementTtl);
blackHole->SetContext(context);
context->entities->push_back(blackHole);
}
void GravityWellComponent::Cleanup() {}

View File

@ -3,7 +3,7 @@
#include "Component.hpp"
/**
* Represents player-controlled gravity source that follows mouse input.
* Handles gravity well behavior for both hazards and player-spawned black holes.
*/
struct GravityWellComponent : public Component {
/**
@ -18,13 +18,12 @@ struct GravityWellComponent : public Component {
float minDist = 30.0f;
/**
* Whether the gravity well is currently active and should apply gravitational forces to
* receivers (e.g. tied to mouse button state).
* Whether the gravity source is currently active and should apply gravitational force.
*/
bool active = false;
/**
* If true, active state is driven by left mouse button and this well follows the cursor.
* If true, this component is used as the player input controller.
*/
bool controlledByMouse = true;
@ -34,9 +33,14 @@ struct GravityWellComponent : public Component {
bool alwaysActive = false;
/**
* Lerp factor for how quickly the gravity well follows the mouse position.
* Meter cost paid to place one player black hole.
*/
float followLerp = 12.0f;
float placementCost = 10.0f;
/**
* Lifetime in seconds for each player-placed black hole.
*/
float placementTtl = 1.0f;
void Setup() override;
void Update(float dt) override;

View File

@ -100,7 +100,7 @@ bool PhysicsComponent::ComputeGravityDeltaVelocity(double px, double py, double
continue;
}
if (probeMeterDepleted && wellGravity->get().controlledByMouse) {
if (probeMeterDepleted && !wellGravity->get().alwaysActive) {
continue;
}

View File

@ -0,0 +1,13 @@
#pragma once
#include "Component.hpp"
/**
* Marker component to identify player-created black holes. It carries no
* behavior and is only used for runtime identification and removal.
*/
struct PlayerBlackHoleComponent : public Component {
void Setup() override {}
void Update(float) override {}
void Cleanup() override {}
};

View File

@ -1,11 +1,14 @@
#include "components/ProjectionComponent.hpp"
#include "Entity.hpp"
#include "components/GravityReceiverComponent.hpp"
#include "components/GravityWellComponent.hpp"
#include "components/PhysicsComponent.hpp"
#include "components/StatsComponent.hpp"
#include "components/TransformComponent.hpp"
#include <algorithm>
#include <cmath>
void ProjectionComponent::Setup() { points.clear(); }
void ProjectionComponent::Update(float) {
@ -18,25 +21,45 @@ void ProjectionComponent::Update(float) {
auto transform = entity->GetComponent<TransformComponent>();
auto physics = entity->GetComponent<PhysicsComponent>();
auto receiver = entity->GetComponent<GravityReceiverComponent>();
if (!transform || !physics || !receiver) {
if (!transform || !physics || !context->entities) {
return;
}
Entity *wellEntity = receiver->get().well ? receiver->get().well : context->wellEntity;
if (!wellEntity) {
return;
float placementCost = 10.0f;
float previewMass = static_cast<float>(1 << 22);
float previewMinDist = 28.0f;
if (context->wellEntity) {
auto controllerWell = context->wellEntity->GetComponent<GravityWellComponent>();
if (controllerWell) {
placementCost = controllerWell->get().placementCost;
if (controllerWell->get().mass > 0.0f) {
previewMass = controllerWell->get().mass;
}
previewMinDist = controllerWell->get().minDist;
}
}
auto wellTransform = wellEntity->GetComponent<TransformComponent>();
auto wellGravity = wellEntity->GetComponent<GravityWellComponent>();
if (!wellTransform || !wellGravity) {
return;
bool canPreviewPlacement = false;
if (context->statsEntity) {
auto stats = context->statsEntity->GetComponent<StatsComponent>();
canPreviewPlacement = stats && stats->get().value >= placementCost;
}
// only highlight if the well is active, inactive will be a lighter color to show the potential
// projection if the player were to activate it
highlightActive = wellGravity->get().active;
const Vector2 mouse = GetMousePosition();
const double previewX = static_cast<double>(mouse.x);
const double previewY = static_cast<double>(mouse.y);
for (auto &other : *context->entities) {
if (!other || other.get() == entity) {
continue;
}
auto wellGravity = other->GetComponent<GravityWellComponent>();
if (wellGravity && wellGravity->get().active) {
highlightActive = true;
break;
}
}
double px = static_cast<double>(transform->get().x);
double py = static_cast<double>(transform->get().y);
@ -46,6 +69,21 @@ void ProjectionComponent::Update(float) {
points.reserve(static_cast<size_t>(steps));
for (int i = 0; i < steps; ++i) {
physics->get().SimulateStep(px, py, vx, vy, static_cast<double>(stepDt), true);
if (canPreviewPlacement && !physics->get().IsInsideNullZone(px)) {
const double dx = previewX - px;
const double dy = previewY - py;
const double dist = std::sqrt(dx * dx + dy * dy);
if (dist > 0.0001) {
const double clampedDist = std::max(dist, static_cast<double>(previewMinDist));
const double force = static_cast<double>(previewMass) / (clampedDist * clampedDist);
const double dt = static_cast<double>(stepDt);
vx += (dx / dist) * force * dt;
vy += (dy / dist) * force * dt;
physics->get().ClampVelocity(vx, vy);
}
}
points.push_back({static_cast<float>(px), static_cast<float>(py)});
}

View File

@ -33,12 +33,12 @@ void SpawnComponent::Update(float) {
while (cursorWX < spawnLimit) {
const float r = static_cast<float>(GetRandomValue(0, 99));
if (r < 50.0f) {
if (r < 70.0f) {
const float y = static_cast<float>(GetRandomValue(48, GetScreenHeight() - 48));
auto star = CreateStar(cursorWX, y);
star->SetContext(context);
context->entities->push_back(star);
} else if (r < 78.0f) {
} else if (r < 90.0f) {
const float y = static_cast<float>(GetRandomValue(42, GetScreenHeight() - 42));
auto asteroid = CreateAsteroid(cursorWX, y);
asteroid->SetContext(context);

View File

@ -10,8 +10,8 @@
struct StatsComponent : public Component {
float value = 60.0f;
float maxValue = 100.0f;
float drainRate = 14.0f;
float gainPerStar = 28.0f;
float drainRate = 10.0f;
float gainPerStar = 50.0f;
int stars = 0;
void Setup() override;

View File

@ -1,5 +1,6 @@
#include "scene/DeathScene.hpp"
#include "EnergyBarRaygui.hpp"
#include "scene/GameplayScene.hpp"
#include "scene/SceneManager.hpp"
#include "scene/StartMenuScene.hpp"
@ -17,4 +18,7 @@ void DeathScene::Update(float) {
void DeathScene::Draw() {
Scene::Draw();
// Draw centered death stats using the HUD helper
// collectedStars is a private member; access via this-> to satisfy older compilers / linters
DrawDeathStats(this->collectedStars);
}

View File

@ -14,6 +14,11 @@ class StartMenuScene;
class DeathScene : public Scene {
public:
explicit DeathScene(SceneManager &owner) : Scene(owner) {}
// Alternate constructor that accepts the star count to display.
explicit DeathScene(SceneManager &owner, int stars) : Scene(owner), collectedStars(stars) {}
void Update(float dt) override;
void Draw() override;
private:
int collectedStars = 0;
};

View File

@ -4,17 +4,23 @@
#include "scene/DeathScene.hpp"
#include "scene/SceneManager.hpp"
#include <algorithm>
void GameplayScene::Enter() {
entities.clear();
entities.reserve(20);
context = {};
wantsDeathScene = false;
collectedCount = 0;
meterValue = 60.0f;
context.onPlayerDeath = [this]() { wantsDeathScene = true; };
context.onPlayerDeath = [this]() {
// Inject the collected star count into the queued DeathScene by passing it
// as a constructor argument. SceneManager::QueueSceneChange supports
// forwarding constructor args.
manager.QueueSceneChange<DeathScene>(collectedCount);
};
context.AddCollectiblePickedListener([this](Entity &collectible) {
++collectedCount;
collectedCount++;
auto transform = collectible.GetComponent<TransformComponent>();
if (!transform || !context.entities) {
@ -56,23 +62,19 @@ void GameplayScene::Enter() {
}
}
void GameplayScene::Update(float dt) {
UpdateAllSystems(entities, dt);
void GameplayScene::Exit() { entities.clear(); }
if (wantsDeathScene) {
manager.QueueSceneChange<DeathScene>();
}
}
void GameplayScene::Update(float dt) { UpdateAllSystems(entities, dt); }
void GameplayScene::Draw() {
std::optional<std::reference_wrapper<ScrollComponent>> worldScroll;
if (context.worldEntity) {
worldScroll = context.worldEntity->GetComponent<ScrollComponent>();
}
std::optional<std::reference_wrapper<TransformComponent>> probeTransform;
if (context.probeEntity) {
probeTransform = context.probeEntity->GetComponent<TransformComponent>();
for (auto &entity : entities) {
if (!entity || entity->queuedForFree) {
continue;
}
auto render = entity->GetComponent<RenderComponent>();
if (render) {
render->get().Draw();
}
}
Scene::Draw();

View File

@ -21,12 +21,12 @@ class GameplayScene : public Scene {
public:
explicit GameplayScene(SceneManager &owner) : Scene(owner) {}
void Enter() override;
void Exit() override;
void Update(float dt) override;
void Draw() override;
private:
GameContext context;
bool wantsDeathScene = false;
int collectedCount = 0;
float meterValue = 60.0f;
};