- BillboardInstance: per-instance uv sub-rect, feet anchor, turn, dithered alpha, flash; BillboardBatch::sharedTexture; Texture createEmpty/update/cellUV/cellAnchor/nearest - SpriteAtlas: fixed-cell runtime atlas with gutters - hd2d::ui: Octopath-style panels, fonts (Cinzel, EB Garamond), dialogue box with typewriter + portraits + choices, menus, nameplates, toasts, ImGui theme - DayCycle (dawn/day/dusk/night keyframes, sun + moon arcs), Weather overcast/mist/ lightning, night-aware presets, stars and moon in the sky - Hidden-window offscreen captures (Window visible=false, Renderer output FBO, OffscreenTarget); --screenshot never opens a window - Camera::worldToScreen; CMake usable via add_subdirectory (HD2D_BUILD_DEMO, HD2D_ENGINE_ASSET_DIR)
505 lines
23 KiB
C++
505 lines
23 KiB
C++
#include "DemoScene.h"
|
|
|
|
#include "hd2d/Log.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
using namespace hd2d;
|
|
|
|
namespace {
|
|
|
|
constexpr int kW = 64, kD = 64;
|
|
constexpr float kWaterLevel = -0.2f;
|
|
constexpr float kPixelsPerUnit = 32.0f; // world texel density shared by all pixel art
|
|
|
|
enum Mat : uint8_t { Grass = 0, Meadow = 1, Path = 2, Sand = 3, Stone = 4, Ridge = 5 };
|
|
|
|
// Terrain texture array layers (see tools/assets.json for the sources).
|
|
const char* kLayers[] = {
|
|
"ground_grass_0", "ground_grass_1", "ground_grass_2", "ground_grass_3", // 0-3 plain grass
|
|
"ground_grass_4", "ground_grass_5", "ground_grass_8", // 4-6 flowers / clover
|
|
"ground_grass_12", "ground_grass_13", // 7-8 dark meadow grass
|
|
"ground_extra_1", // 9 earth path
|
|
"ground_extra_4", "ground_extra_5", // 10-11 earth cliff
|
|
"ground_misc_4", "ground_misc_5", // 12-13 mossy stone cliff
|
|
"ground_extra_8", "ground_extra_9", // 14-15 sand
|
|
"ground_misc_12", "ground_misc_13", // 16-17 cobblestone
|
|
};
|
|
|
|
float ridgeLow(float x) { return 16.0f + 2.5f * std::sin(x * 0.21f) + 1.5f * std::sin(x * 0.53f + 1.3f); }
|
|
float ridgeHigh(float x) { return 8.0f + 2.0f * std::sin(x * 0.17f + 2.0f) + 1.2f * std::sin(x * 0.61f); }
|
|
|
|
// Distance from a wobbly blob's center, normalized so the edge sits at `radius`.
|
|
float blob(glm::vec2 p, glm::vec2 c, float radius, float seed) {
|
|
glm::vec2 d = p - c;
|
|
float a = std::atan2(d.y, d.x);
|
|
float wobble = 1.0f + 0.16f * std::sin(a * 3.0f + seed) + 0.08f * std::sin(a * 5.0f + seed * 2.3f);
|
|
return glm::length(d) / (radius * wobble);
|
|
}
|
|
|
|
const glm::vec2 kPondCenter{22.0f, 36.0f};
|
|
const glm::vec2 kClearing{33.5f, 44.5f}; // kept free of trees and rocks
|
|
const glm::vec2 kStart{13.0f, 44.0f}; // player start: tall grass beside the pond
|
|
|
|
struct Patch {
|
|
glm::vec2 c;
|
|
float r;
|
|
};
|
|
const Patch kTallGrass[] = {{{12, 47}, 5.5f}, {{46, 28}, 4.2f}, {{29, 52}, 3.2f}, {{54, 52}, 4.5f}, {{20, 12}, 3.5f}};
|
|
|
|
// Stone bridge across the pond along +X. Must match BRIDGE_* and deck_z() in
|
|
// tools/blender_scenery.py.
|
|
const glm::vec2 kBridgeCenter = kPondCenter;
|
|
constexpr float kBridgeLen = 17.0f, kBridgeWidth = 2.6f, kBridgeRise = 0.7f, kBridgeWall = 0.28f;
|
|
|
|
float bridgeDeck(float localX) {
|
|
float u = std::clamp(2.0f * localX / kBridgeLen, -1.0f, 1.0f);
|
|
return 0.05f + kBridgeRise * (1.0f - u * u);
|
|
}
|
|
// Inside the corridor around the bridge (kept free of props), with a margin.
|
|
bool nearBridge(glm::vec2 p, float margin) {
|
|
glm::vec2 d = glm::abs(p - kBridgeCenter);
|
|
return d.x < kBridgeLen * 0.5f + margin && d.y < kBridgeWidth * 0.5f + margin;
|
|
}
|
|
bool onBridgeDeck(glm::vec2 p) {
|
|
glm::vec2 d = glm::abs(p - kBridgeCenter);
|
|
return d.x <= kBridgeLen * 0.5f + 0.2f && d.y < kBridgeWidth * 0.5f - kBridgeWall - 0.15f;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool DemoScene::load(const std::string& assetDir) {
|
|
const std::string tex = assetDir + "/textures/";
|
|
std::vector<std::string> layers;
|
|
for (const char* l : kLayers) layers.push_back(tex + l + ".png");
|
|
if (!terrain_.textures.load(layers)) return false;
|
|
|
|
struct {
|
|
BillboardBatch* batch;
|
|
const char* file;
|
|
} sprites[] = {{&tallGrass_, "tall_grass.png"}, {&tufts_, "grass_tuft.png"}, {&flowers_, "flowers.png"}};
|
|
for (auto& s : sprites)
|
|
if (!s.batch->loadTexture(tex + s.file)) return false;
|
|
if (!player_.loadTexture(assetDir + "/sprites/test_sprite.png")) return false;
|
|
|
|
// 3D scenery (built by tools/blender_scenery.py).
|
|
if (!models_.load(assetDir + "/models/scenery.glb")) return false;
|
|
for (auto& [batch, name] : {std::pair{&oaks_[0], "oak_a"}, {&oaks_[1], "oak_b"}, {&pines_[0], "pine_a"},
|
|
{&pines_[1], "pine_b"}, {&bushes_[0], "bush_a"}, {&bushes_[1], "bush_b"},
|
|
{&rocks_[0], "rock_a"}, {&rocks_[1], "rock_b"}}) {
|
|
batch->model = models_.get(name);
|
|
if (!batch->model) return false;
|
|
}
|
|
bridge_.model = models_.get("bridge");
|
|
if (!bridge_.model) return false;
|
|
bridge_.instances = {{{kBridgeCenter.x, 0.0f, kBridgeCenter.y}, 0.0f, glm::vec3(1.0f), 1.0f}};
|
|
bridge_.occluderFade = false; // low; the camera sees over it
|
|
bridge_.upload();
|
|
// Fires burn in the braziers on the bridge's entrance pillars.
|
|
for (const auto& [name, local] : bridge_.model->sockets)
|
|
if (name.rfind("fire", 0) == 0) particles_.addFire(bridge_.instances[0].toWorld(local), 0.9f);
|
|
|
|
// Contact occlusion under scenery (tree bases, bushes, rocks).
|
|
for (ModelBatch& b : oaks_) b.groundOcclusion = 1.0f;
|
|
for (ModelBatch& b : pines_) b.groundOcclusion = 1.0f;
|
|
for (ModelBatch& b : bushes_) b.groundOcclusion = 0.8f;
|
|
for (ModelBatch& b : rocks_) b.groundOcclusion = 0.7f;
|
|
for (ModelBatch& b : rocks_) b.occluderFade = false;
|
|
for (ModelBatch& b : bushes_) b.occluderFade = false;
|
|
|
|
tufts_.inReflections = false; // tiny, never visible in the pond
|
|
flowers_.inReflections = false;
|
|
player_.emissive = 0.06f;
|
|
|
|
buildTerrain();
|
|
scatterProps();
|
|
std::vector<ModelBatch*> occluders;
|
|
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
|
|
for (ModelBatch& b : *group) occluders.push_back(&b);
|
|
groundAO_.build({0.0f, 0.0f}, {float(kW), float(kD)}, occluders);
|
|
|
|
// Pond water plane, slightly larger than the basin.
|
|
water_.build(kPondCenter - glm::vec2(9.0f), kPondCenter + glm::vec2(9.0f), kWaterLevel);
|
|
water_.buildShoreField(terrain_);
|
|
|
|
particles_.addField(ParticleSystem::Kind::Firefly, {4, 0.2f, 26}, {58, 1.8f, 58}, 45);
|
|
particles_.addField(ParticleSystem::Kind::Mote, {6, 0.0f, 4}, {58, 7.0f, 60}, 650);
|
|
|
|
scene.terrain = &terrain_;
|
|
scene.water = &water_;
|
|
scene.particles = &particles_;
|
|
scene.billboards = {&tallGrass_, &tufts_, &flowers_, &player_};
|
|
scene.models.clear();
|
|
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
|
|
for (ModelBatch& b : *group) scene.models.push_back(&b);
|
|
scene.models.push_back(&bridge_);
|
|
scene.groundOcclusion = &groundAO_;
|
|
scene.volumetricLights = int(particles_.fires().size());
|
|
scene.interactorRadius = 1.0f;
|
|
scene.blobShadowRadius = 0.55f;
|
|
|
|
player_.instances.resize(1);
|
|
placePlayer(kStart);
|
|
return !spriteTest || buildSpriteTest(assetDir);
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Lowest row (from the bottom) with alpha >= 128, or 0 if none.
|
|
int lowestOpaqueRow(const Image& img) {
|
|
for (int y = 0; y < img.height; ++y)
|
|
for (int x = 0; x < img.width; ++x)
|
|
if (img.rgba[(size_t(y) * img.width + x) * 4 + 3] >= 128) return y;
|
|
return 0;
|
|
}
|
|
|
|
// Copies `src` into `dst` with its bottom-left pixel at (x, y).
|
|
void blit(Image& dst, const Image& src, int x, int y) {
|
|
for (int r = 0; r < src.height; ++r)
|
|
std::copy_n(&src.rgba[size_t(r) * src.width * 4], size_t(src.width) * 4,
|
|
&dst.rgba[(size_t(y + r) * dst.width + x) * 4]);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool DemoScene::buildSpriteTest(const std::string& assetDir) {
|
|
Image big, flowers, tuft;
|
|
if (!loadImage(assetDir + "/sprites/test_sprite.png", big) || !loadImage(assetDir + "/textures/flowers.png", flowers) ||
|
|
!loadImage(assetDir + "/textures/grass_tuft.png", tuft))
|
|
return false;
|
|
// The test sprite is 4x upscaled 64 px art: take one pixel per 4x4 block.
|
|
Image hero;
|
|
hero.width = big.width / 4;
|
|
hero.height = big.height / 4;
|
|
hero.rgba.resize(size_t(hero.width) * hero.height * 4);
|
|
for (int y = 0; y < hero.height; ++y)
|
|
for (int x = 0; x < hero.width; ++x)
|
|
std::copy_n(&big.rgba[((size_t(y) * 4 + 2) * big.width + x * 4 + 2) * 4], 4,
|
|
&hero.rgba[(size_t(y) * hero.width + x) * 4]);
|
|
|
|
// Runtime atlas: add, then exercise remove/re-add so slots get reused.
|
|
if (!atlas_.init({64, 64}, 4, 2)) return false;
|
|
atlasSlots_[0] = atlas_.add(hero);
|
|
int scratch = atlas_.add(big); // cropped to the cell
|
|
atlas_.remove(scratch);
|
|
atlasSlots_[1] = atlas_.add(flowers); // reuses the freed slot
|
|
atlasSlots_[2] = atlas_.add(tuft);
|
|
HD2D_LOG("sprite test atlas: %d/%d slots used", atlas_.used(), atlas_.capacity());
|
|
atlasSprites_.sharedTexture = &atlas_.texture();
|
|
atlasSprites_.instances.resize(6);
|
|
|
|
// 2x1 sprite sheet (authored top-down, one row): hero | flowers.
|
|
Image sheet;
|
|
sheet.width = 128;
|
|
sheet.height = 64;
|
|
sheet.rgba.assign(size_t(sheet.width) * sheet.height * 4, 0);
|
|
blit(sheet, hero, 0, 0);
|
|
blit(sheet, flowers, 64 + (64 - flowers.width) / 2, 0);
|
|
bleedTransparentColors(sheet);
|
|
if (!sheetSprites_.texture.create(sheet)) return false;
|
|
sheetFeetRows_[0] = 63 - lowestOpaqueRow(hero);
|
|
sheetFeetRows_[1] = 63 - lowestOpaqueRow(flowers);
|
|
sheetSprites_.instances.resize(1);
|
|
|
|
atlasSprites_.emissive = sheetSprites_.emissive = player_.emissive;
|
|
scene.billboards.push_back(&atlasSprites_);
|
|
scene.billboards.push_back(&sheetSprites_);
|
|
return true;
|
|
}
|
|
|
|
void DemoScene::updateSpriteTest(float time, const Camera& camera) {
|
|
glm::vec3 right = camera.right();
|
|
glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward
|
|
const glm::vec2 size = BillboardBatch::cellSize(atlas_.cellPixels(), kPixelsPerUnit);
|
|
labels.clear();
|
|
auto place = [&](BillboardInstance& bi, int index, const char* label) {
|
|
glm::vec3 p = playerPos - fwd * 1.8f + right * (float(index) - 3.0f); // row in front of the player
|
|
bi = BillboardInstance{};
|
|
bi.pos = glm::vec3(p.x, groundHeight({p.x, p.z}), p.z);
|
|
bi.size = size;
|
|
labels.push_back({bi.pos + glm::vec3(0, size.y + 0.15f, 0), label});
|
|
};
|
|
auto useSlot = [&](BillboardInstance& bi, int slot) {
|
|
bi.uv = atlas_.uv(slot);
|
|
bi.anchor = atlas_.anchor(slot);
|
|
};
|
|
auto& a = atlasSprites_.instances;
|
|
const char* names[] = {"atlas", "flip", "turn", "alpha", "flash", "frames"};
|
|
for (int i = 0; i < 6; ++i) {
|
|
place(a[i], i, names[i]);
|
|
useSlot(a[i], atlasSlots_[0]);
|
|
}
|
|
a[1].flip = 1.0f;
|
|
a[2].turn = 0.8f + 0.4f * std::sin(time * 1.5f);
|
|
a[3].alpha = 0.5f + 0.3f * std::sin(time * 2.0f);
|
|
a[4].flash = 0.5f + 0.5f * std::sin(time * 5.0f);
|
|
useSlot(a[5], atlasSlots_[int(time / 0.4f) % 3]);
|
|
atlasSprites_.upload();
|
|
|
|
BillboardInstance& s = sheetSprites_.instances[0];
|
|
place(s, 6, "sheet");
|
|
int frame = int(time / 0.5f) % 2;
|
|
glm::ivec2 texSize = sheetSprites_.texture.size();
|
|
s.uv = Texture::cellUV(texSize, {64, 64}, frame, 0);
|
|
s.anchor = Texture::cellAnchor(texSize, {64, 64}, 0, sheetFeetRows_[frame]);
|
|
sheetSprites_.upload();
|
|
}
|
|
|
|
void DemoScene::buildTerrain() {
|
|
Terrain& t = terrain_;
|
|
t.init(kW, kD, 0.5f);
|
|
t.baseLevel = -8;
|
|
// topFirst, topCount, accentFirst, accentCount, accentChance, sideFirst, sideCount, lipLayer
|
|
t.materials[Grass] = {0, 4, 4, 3, 0.10f, 10, 2, 0};
|
|
t.materials[Meadow] = {7, 2, 0, 4, 0.15f, 10, 2, 7};
|
|
t.materials[Path] = {9, 1, 0, 0, 0.0f, 10, 2, -1};
|
|
t.materials[Sand] = {14, 2, 0, 0, 0.0f, 14, 2, -1};
|
|
t.materials[Stone] = {16, 2, 0, 0, 0.0f, 12, 2, -1};
|
|
t.materials[Ridge] = {0, 4, 7, 2, 0.2f, 12, 2, 0};
|
|
|
|
tallGrassMask_.assign(size_t(kW) * kD, 0);
|
|
|
|
for (int z = 0; z < kD; ++z)
|
|
for (int x = 0; x < kW; ++x) {
|
|
glm::vec2 p(float(x) + 0.5f, float(z) + 0.5f);
|
|
int level = 0;
|
|
uint8_t mat = Grass;
|
|
if (p.y < ridgeLow(p.x)) level = 2, mat = Ridge;
|
|
if (p.y < ridgeHigh(p.x)) level = 4, mat = Ridge;
|
|
|
|
float pond = blob(p, kPondCenter, 6.3f, 0.7f);
|
|
if (pond < 1.17f) mat = Sand; // beach ring
|
|
if (pond < 1.0f) level = -1; // shallow shelf
|
|
if (pond < 0.72f) level = -3; // deep basin
|
|
|
|
if (blob(p, {47.0f, 40.0f}, 3.6f, 2.1f) < 1.0f) level = 1; // small mound
|
|
|
|
if (level >= 0 && mat != Sand)
|
|
for (const Patch& tg : kTallGrass)
|
|
if (blob(p, tg.c, tg.r, tg.c.x) < 1.0f) {
|
|
mat = Meadow;
|
|
tallGrassMask_[size_t(z) * kW + x] = 1;
|
|
}
|
|
t.setLevel(x, z, level);
|
|
t.setMaterial(x, z, mat);
|
|
}
|
|
|
|
// Winding earth path from the south edge up to the ridge stairs.
|
|
const glm::vec2 path[] = {{33, 64}, {33, 56}, {36, 48}, {40, 40}, {41.5f, 31}, {40.5f, 24}, {40.5f, 2}};
|
|
for (size_t i = 0; i + 1 < std::size(path); ++i) {
|
|
glm::vec2 a = path[i], b = path[i + 1];
|
|
int steps = int(glm::length(b - a) * 4.0f);
|
|
for (int s = 0; s <= steps; ++s) {
|
|
glm::vec2 q = glm::mix(a, b, float(s) / float(std::max(steps, 1)));
|
|
for (int dz = -2; dz <= 2; ++dz)
|
|
for (int dx = -2; dx <= 2; ++dx) {
|
|
int x = int(std::floor(q.x)) + dx, z = int(std::floor(q.y)) + dz;
|
|
if (!t.inBounds(x, z) || t.level(x, z) < 0) continue;
|
|
if (glm::length(glm::vec2(float(x) + 0.5f, float(z) + 0.5f) - q) > 1.25f) continue;
|
|
t.setMaterial(x, z, Path);
|
|
tallGrassMask_[size_t(z) * kW + x] = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Cobblestone stairs where the path climbs each ridge (one level per step).
|
|
for (int x = 39; x <= 41; ++x) {
|
|
int zLow = int(std::ceil(ridgeLow(float(x) + 0.5f) - 0.5f)); // first level-0 tile south of ridge
|
|
int zHigh = int(std::ceil(ridgeHigh(float(x) + 0.5f) - 0.5f)); // first level-2 tile south of top
|
|
for (int z = zHigh; z < zLow; ++z) t.setMaterial(x, z, Path);
|
|
t.setLevel(x, zLow, 1);
|
|
t.setMaterial(x, zLow, Stone);
|
|
t.setLevel(x, zHigh, 3);
|
|
t.setMaterial(x, zHigh, Stone);
|
|
}
|
|
t.build();
|
|
}
|
|
|
|
bool DemoScene::occupied(glm::vec2 p, float r) const {
|
|
for (const Obstacle& o : obstacles_)
|
|
if (glm::length(o.pos - p) < o.radius + r) return true;
|
|
return false;
|
|
}
|
|
|
|
void DemoScene::scatterProps() {
|
|
std::uniform_real_distribution<float> u(0.0f, 1.0f);
|
|
const Terrain& t = terrain_;
|
|
auto tint = [&] { return 0.86f + 0.22f * u(rng_); };
|
|
auto groundOk = [&](int x, int z) {
|
|
uint8_t m = t.material(x, z);
|
|
return t.level(x, z) >= 0 && m != Path && m != Sand && m != Stone;
|
|
};
|
|
|
|
// Trees: dense forest band at the diorama border, sparse elsewhere.
|
|
for (int z = 0; z < kD; ++z)
|
|
for (int x = 0; x < kW; ++x) {
|
|
if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue;
|
|
int edge = std::min({x, z, kW - 1 - x, kD - 1 - z});
|
|
float chance = edge < 6 ? 0.45f - float(edge) * 0.05f : (t.level(x, z) >= 4 ? 0.14f : 0.012f);
|
|
if (u(rng_) > chance) continue;
|
|
glm::vec2 p(float(x) + 0.2f + 0.6f * u(rng_), float(z) + 0.2f + 0.6f * u(rng_));
|
|
if (glm::length(p - kClearing) < 5.0f || nearBridge(p, 2.5f) || occupied(p, 1.3f)) continue;
|
|
if (blob(p, kPondCenter, 6.3f, 0.7f) < 1.35f) continue;
|
|
bool pine = u(rng_) < (t.level(x, z) >= 2 || edge < 6 ? 0.55f : 0.25f);
|
|
auto& group = pine ? pines_ : oaks_;
|
|
float scale = (pine ? 0.75f : 0.8f) + 0.3f * u(rng_);
|
|
float squash = 0.92f + 0.16f * u(rng_); // breaks up silhouettes of repeated models
|
|
group[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
|
|
{{p.x, t.heightAt(p.x, p.y), p.y}, u(rng_) * 6.2832f, glm::vec3(scale, scale * squash, scale), tint()});
|
|
obstacles_.push_back({p, 0.45f * scale});
|
|
}
|
|
|
|
// Bushes and rocks (rocks gather around the pond).
|
|
for (int z = 1; z < kD - 1; ++z)
|
|
for (int x = 1; x < kW - 1; ++x) {
|
|
glm::vec2 p(float(x) + 0.5f + 0.4f * (u(rng_) - 0.5f), float(z) + 0.5f + 0.4f * (u(rng_) - 0.5f));
|
|
if (t.level(x, z) < 0 || t.material(x, z) == Path || t.material(x, z) == Stone) continue;
|
|
if (glm::length(p - kClearing) < 3.0f || nearBridge(p, 1.0f) || occupied(p, 0.9f)) continue;
|
|
float pond = blob(p, kPondCenter, 6.3f, 0.7f);
|
|
bool nearPond = pond > 1.0f && pond < 1.3f;
|
|
float r = u(rng_);
|
|
if (r < (nearPond ? 0.12f : 0.008f)) {
|
|
float s = 0.8f + 0.5f * u(rng_);
|
|
rocks_[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
|
|
{{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, u(rng_) * 6.2832f,
|
|
glm::vec3(s, s * (0.8f + 0.4f * u(rng_)), s), tint()});
|
|
obstacles_.push_back({p, 0.42f * s});
|
|
} else if (groundOk(x, z) && !tallGrassMask_[size_t(z) * kW + x] && r < (nearPond ? 0.17f : 0.03f)) {
|
|
float s = 0.8f + 0.45f * u(rng_);
|
|
bushes_[u(rng_) < 0.5f ? 0 : 1].instances.push_back(
|
|
{{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, u(rng_) * 6.2832f, glm::vec3(s), tint()});
|
|
obstacles_.push_back({p, 0.5f * s});
|
|
}
|
|
}
|
|
|
|
// Pokemon-style tall grass: dense overlapping rows of swaying clumps.
|
|
for (int z = 0; z < kD; ++z)
|
|
for (int x = 0; x < kW; ++x) {
|
|
if (!tallGrassMask_[size_t(z) * kW + x]) continue;
|
|
for (int row = 0; row < 2; ++row)
|
|
for (int col = 0; col < 2; ++col) {
|
|
glm::vec2 p(float(x) + 0.25f + 0.5f * float(col) + 0.15f * (u(rng_) - 0.5f),
|
|
float(z) + 0.3f + 0.5f * float(row) + 0.1f * (u(rng_) - 0.5f));
|
|
if (nearBridge(p, 0.5f)) continue;
|
|
float s = 1.0f + 0.2f * u(rng_);
|
|
tallGrass_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.03f, p.y}, 1.0f,
|
|
glm::vec2(1.1f, 1.1f) * s, u(rng_) < 0.5f ? 1.0f : 0.0f,
|
|
tint()});
|
|
}
|
|
}
|
|
|
|
// Short grass tufts and flowers sprinkled across open grass.
|
|
for (int z = 0; z < kD; ++z)
|
|
for (int x = 0; x < kW; ++x) {
|
|
if (!groundOk(x, z) || tallGrassMask_[size_t(z) * kW + x]) continue;
|
|
// Clustered, darker tufts read as texture variation instead of stickers.
|
|
float cluster = 0.5f + 0.5f * std::sin(float(x) * 0.35f + 1.7f) * std::sin(float(z) * 0.29f + 0.4f);
|
|
int n = int(u(rng_) * (0.3f + 2.6f * cluster * cluster));
|
|
for (int i = 0; i < n; ++i) {
|
|
glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_));
|
|
if (nearBridge(p, 0.3f)) continue;
|
|
float s = 0.6f + 0.45f * u(rng_);
|
|
tufts_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 1.0f,
|
|
tufts_.sizeForDensity(kPixelsPerUnit) * s, u(rng_) < 0.5f ? 1.0f : 0.0f,
|
|
0.62f + 0.18f * u(rng_)});
|
|
}
|
|
if (u(rng_) < 0.07f) {
|
|
glm::vec2 p(float(x) + u(rng_), float(z) + u(rng_));
|
|
if (nearBridge(p, 0.3f)) continue;
|
|
flowers_.instances.push_back({{p.x, t.heightAt(p.x, p.y) - 0.02f, p.y}, 0.8f,
|
|
flowers_.sizeForDensity(kPixelsPerUnit) * (0.7f + 0.3f * u(rng_)),
|
|
u(rng_) < 0.5f ? 1.0f : 0.0f, 1.0f});
|
|
}
|
|
}
|
|
|
|
for (BillboardBatch* b : {&tallGrass_, &tufts_, &flowers_}) b->upload();
|
|
auto count = [](const std::array<ModelBatch, 2>& g) { return g[0].instances.size() + g[1].instances.size(); };
|
|
for (auto* group : {&oaks_, &pines_, &bushes_, &rocks_})
|
|
for (ModelBatch& b : *group) b.upload();
|
|
HD2D_LOG("props: %zu oaks, %zu pines, %zu bushes, %zu rocks, %zu tall grass, %zu tufts, %zu flowers",
|
|
count(oaks_), count(pines_), count(bushes_), count(rocks_), tallGrass_.instances.size(),
|
|
tufts_.instances.size(), flowers_.instances.size());
|
|
}
|
|
|
|
bool DemoScene::walkable(glm::vec2 from, glm::vec2 to) const {
|
|
const Terrain& t = terrain_;
|
|
if (to.x < 1.0f || to.y < 1.0f || to.x > float(kW - 1) || to.y > float(kD - 1)) return false;
|
|
bool deckTo = onBridgeDeck(to), deckFrom = onBridgeDeck(from);
|
|
// Parapets and entrance pillars block the sides of the deck; the ends stay open.
|
|
glm::vec2 rel = glm::abs(to - kBridgeCenter);
|
|
if (!deckTo && nearBridge(to, 0.6f) && rel.x < kBridgeLen * 0.5f + 0.3f &&
|
|
rel.y >= kBridgeWidth * 0.5f - kBridgeWall - 0.15f)
|
|
return false;
|
|
if (!deckTo) {
|
|
int fl = t.level(int(std::floor(from.x)), int(std::floor(from.y)));
|
|
int tl = t.level(int(std::floor(to.x)), int(std::floor(to.y)));
|
|
if (tl < 0) return false; // pond
|
|
if (!deckFrom && (tl > fl + 1 || tl < fl - 2)) return false; // step up one level, drop at most two
|
|
}
|
|
for (const Obstacle& o : obstacles_) {
|
|
float r = o.radius + 0.28f;
|
|
if (glm::length(o.pos - to) < r && glm::length(o.pos - to) < glm::length(o.pos - from)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
float DemoScene::groundHeight(glm::vec2 p) const {
|
|
return onBridgeDeck(p) ? bridgeDeck(p.x - kBridgeCenter.x) : terrain_.heightAt(p.x, p.y);
|
|
}
|
|
|
|
void DemoScene::placePlayer(glm::vec2 xz) { playerPos = glm::vec3(xz.x, groundHeight(xz), xz.y); }
|
|
|
|
void DemoScene::update(float dt, float time, glm::vec2 move, const Camera& camera) {
|
|
glm::vec3 right = camera.right();
|
|
glm::vec3 fwd(right.z, 0.0f, -right.x); // horizontal camera forward
|
|
glm::vec3 dir = right * move.x + fwd * move.y;
|
|
float amount = std::min(glm::length(dir), 1.0f);
|
|
bool moving = amount > 0.01f;
|
|
|
|
if (moving) {
|
|
dir = glm::normalize(dir) * amount;
|
|
glm::vec2 from(playerPos.x, playerPos.z);
|
|
glm::vec2 step = glm::vec2(dir.x, dir.z) * playerSpeed_ * dt;
|
|
// Axis-separated resolution lets the player slide along obstacles.
|
|
glm::vec2 p = from;
|
|
if (walkable(p, p + glm::vec2(step.x, 0))) p.x += step.x;
|
|
if (walkable(p, p + glm::vec2(0, step.y))) p.y += step.y;
|
|
playerPos.x = p.x;
|
|
playerPos.z = p.y;
|
|
if (std::abs(move.x) > 0.1f) facing_ = move.x > 0.0f ? 1.0f : 0.0f; // sprite looks left by default
|
|
walkPhase_ += dt * 13.0f;
|
|
} else {
|
|
walkPhase_ = 0.0f;
|
|
}
|
|
float ground = groundHeight({playerPos.x, playerPos.z});
|
|
playerPos.y += (ground - playerPos.y) * std::min(1.0f, dt * 14.0f);
|
|
|
|
int tx = int(std::floor(playerPos.x)), tz = int(std::floor(playerPos.z));
|
|
bool inTallGrass = terrain_.inBounds(tx, tz) && tallGrassMask_[size_t(tz) * kW + tx];
|
|
stepTimer_ -= dt;
|
|
if (moving && stepTimer_ <= 0.0f) {
|
|
stepTimer_ = inTallGrass ? 0.12f : 0.25f;
|
|
glm::vec3 feet(playerPos.x, ground + 0.05f, playerPos.z);
|
|
if (inTallGrass) particles_.burst(feet + glm::vec3(0, 0.3f, 0), 3, {0.20f, 0.42f, 0.10f}, 1.6f, 0.05f, 0.7f);
|
|
else particles_.burst(feet, 2, {0.32f, 0.27f, 0.20f}, 0.7f, 0.06f, 0.5f);
|
|
}
|
|
|
|
float hop = moving ? std::abs(std::sin(walkPhase_)) * 0.09f : 0.0f;
|
|
BillboardInstance& pi = player_.instances[0];
|
|
pi.pos = glm::vec3(playerPos.x, playerPos.y + hop - 0.02f, playerPos.z);
|
|
pi.size = player_.sizeForDensity(4.0f * kPixelsPerUnit); // art is 4x upscaled
|
|
pi.flip = facing_;
|
|
pi.tint = 1.0f;
|
|
player_.upload();
|
|
|
|
particles_.update(dt, time);
|
|
scene.lights.clear();
|
|
particles_.collectLights(scene.lights, 12); // fires first, then fireflies
|
|
|
|
scene.interactor = playerPos;
|
|
scene.blobShadowPos = glm::vec3(playerPos.x, ground, playerPos.z);
|
|
scene.focusPoint = playerPos + glm::vec3(0, 0.6f, 0);
|
|
if (spriteTest) updateSpriteTest(time, camera);
|
|
}
|