diff --git a/data/WorldGraphs/dirt.json b/data/WorldGraphs/dirt.json index 2950115..246901d 100644 --- a/data/WorldGraphs/dirt.json +++ b/data/WorldGraphs/dirt.json @@ -30,52 +30,28 @@ 76.0 ], "21": [ - -380.255615234375, - 459.421142578125 + -786.255615234375, + 714.421142578125 ], "23": [ - -154.0, - 248.0 + -560.0, + 503.0 ], "24": [ - 175.0, - 331.0 - ], - "25": [ - -523.0, - 220.0 - ], - "26": [ - -655.7763671875, - 236.2835693359375 - ], - "27": [ - -817.7763671875, - 418.2835693359375 - ], - "28": [ - -816.0, - 249.0 - ], - "29": [ - -357.0, - 248.0 - ], - "30": [ - -516.0, - 408.0 + -231.0, + 586.0 ], "31": [ - -7.0, - 389.0 + -413.0, + 644.0 ], "32": [ - -588.0, - 597.0 + -964.0, + 877.0 ], "33": [ - -151.0, - 497.0 + -557.0, + 752.0 ], "34": [ -1580.6732177734375, @@ -121,25 +97,101 @@ -799.0, 33.0 ], + "47": [ + -249.0374755859375, + 282.7857666015625 + ], + "48": [ + 4.9625244140625, + 268.7857666015625 + ], + "49": [ + -120.0374755859375, + 356.7857666015625 + ], "5": [ -122.0, -513.0 ], + "50": [ + -752.0, + 510.0 + ], + "53": [ + -1203.0, + 545.0 + ], + "54": [ + -963.0, + 628.0 + ], + "55": [ + -1163.0, + 821.0 + ], "6": [ -275.0, -428.0 ], + "60": [ + -109.0, + 825.0 + ], + "61": [ + 165.0, + 1026.0 + ], + "62": [ + 177.0, + 795.0 + ], + "63": [ + 36.0, + 1016.0 + ], + "64": [ + -112.0, + 1031.0 + ], + "66": [ + -260.0, + 834.0 + ], + "67": [ + -430.78302001953125, + 830.218505859375 + ], + "68": [ + 43.0, + 809.0 + ], "7": [ -1.0, -448.0 ], + "72": [ + -560.782958984375, + 1034.2183837890625 + ], + "74": [ + -558.856201171875, + 1217.7781982421875 + ], + "75": [ + -259.782958984375, + 1078.2183837890625 + ], + "76": [ + -400.782958984375, + 1089.2183837890625 + ], "9": [ 158.0, -530.0 ], "__worldOutput": [ - 400.0, - 0.0 + 367.0, + 540.0 ] }, "previewOriginX": 0, @@ -149,6 +201,8 @@ "worldOutputPasses": [ 9, 18, + 48, + 62, 24 ] }, @@ -215,32 +269,7 @@ "to": 24 }, { - "from": 26, - "slot": 0, - "to": 25 - }, - { - "from": 28, - "slot": 0, - "to": 26 - }, - { - "from": 27, - "slot": 1, - "to": 26 - }, - { - "from": 25, - "slot": 0, - "to": 29 - }, - { - "from": 30, - "slot": 1, - "to": 29 - }, - { - "from": 29, + "from": 50, "slot": 0, "to": 31 }, @@ -255,7 +284,7 @@ "to": 33 }, { - "from": 32, + "from": 54, "slot": 1, "to": 33 }, @@ -308,9 +337,84 @@ "from": 46, "slot": 1, "to": 45 + }, + { + "from": 47, + "slot": 0, + "to": 48 + }, + { + "from": 49, + "slot": 1, + "to": 48 + }, + { + "from": 53, + "slot": 0, + "to": 54 + }, + { + "from": 55, + "slot": 1, + "to": 54 + }, + { + "from": 75, + "slot": 0, + "to": 60 + }, + { + "from": 66, + "slot": 1, + "to": 60 + }, + { + "from": 68, + "slot": 0, + "to": 62 + }, + { + "from": 61, + "slot": 1, + "to": 62 + }, + { + "from": 60, + "slot": 0, + "to": 63 + }, + { + "from": 64, + "slot": 1, + "to": 63 + }, + { + "from": 67, + "slot": 0, + "to": 68 + }, + { + "from": 63, + "slot": 1, + "to": 68 + }, + { + "from": 76, + "slot": 0, + "to": 75 + }, + { + "from": 72, + "slot": 0, + "to": 76 + }, + { + "from": 74, + "slot": 1, + "to": 76 } ], - "nextId": 47, + "nextId": 77, "nodes": [ { "frequency": 0.07880000025033951, @@ -389,32 +493,6 @@ "id": 24, "type": "IntBranch" }, - { - "id": 25, - "type": "Floor" - }, - { - "id": 26, - "type": "Add" - }, - { - "id": 27, - "type": "Constant", - "value": 0.05999999865889549 - }, - { - "id": 28, - "type": "Random" - }, - { - "id": 29, - "type": "GreaterEqual" - }, - { - "id": 30, - "type": "Constant", - "value": 1.0 - }, { "id": 31, "type": "And" @@ -473,6 +551,99 @@ "id": 46, "type": "Constant", "value": 0.0 + }, + { + "id": 47, + "maxDepth": 2, + "maxWidth": 8, + "type": "QueryLiquid" + }, + { + "id": 48, + "type": "IntBranch" + }, + { + "id": 49, + "tileId": 4, + "type": "TileID" + }, + { + "frequency": 0.358599990606308, + "id": 50, + "type": "CellularNoise" + }, + { + "id": 53, + "maxX": 0, + "maxY": 3, + "minX": 0, + "minY": 0, + "tileId": 0, + "type": "QueryRange" + }, + { + "id": 54, + "type": "Equal" + }, + { + "id": 55, + "type": "Constant", + "value": 3.0 + }, + { + "id": 60, + "type": "Multiply" + }, + { + "id": 61, + "tileId": 5, + "type": "TileID" + }, + { + "id": 62, + "type": "IntBranch" + }, + { + "id": 63, + "type": "Greater" + }, + { + "id": 64, + "type": "Constant", + "value": 0.09000000357627869 + }, + { + "id": 66, + "type": "Random" + }, + { + "expectedID": 2, + "id": 67, + "offsetX": 0, + "offsetY": 0, + "type": "QueryTile" + }, + { + "id": 68, + "type": "And" + }, + { + "frequency": 0.05400000140070915, + "id": 72, + "type": "SimplexNoise" + }, + { + "id": 74, + "type": "Constant", + "value": 0.7599999904632568 + }, + { + "id": 75, + "type": "Ceil" + }, + { + "id": 76, + "type": "Subtract" } ] } diff --git a/data/tiles.json b/data/tiles.json index 6b373e0..43d4a49 100644 --- a/data/tiles.json +++ b/data/tiles.json @@ -35,6 +35,24 @@ ], "id": 3, "name": "Tree" + }, + { + "color": [ + 0.5555469989776611, + 0.15063799917697906, + 0.8152173757553101 + ], + "id": 4, + "name": "Water" + }, + { + "color": [ + 1.0, + 0.782608687877655, + 0.0 + ], + "id": 5, + "name": "Clay" } ] } \ No newline at end of file diff --git a/include/WorldGraph/WorldGraphNode.h b/include/WorldGraph/WorldGraphNode.h index 63373e6..77c7057 100644 --- a/include/WorldGraph/WorldGraphNode.h +++ b/include/WorldGraph/WorldGraphNode.h @@ -320,7 +320,7 @@ public: QueryDistanceNode(int32_t id, int32_t maxDist) : tileID(id), maxDistance(maxDist) {} - Type GetOutputType() const override { return Type::Int; } + Type GetOutputType() const override { return Type::Float; } std::vector GetInputTypes() const override { return {}; } std::string GetName() const override { return "QueryDistance"; } Value Evaluate(const EvalContext& ctx, const std::vector&) const override { @@ -333,7 +333,57 @@ public: best = d; } } - return Value::MakeInt(best); + return Value::MakeFloat(1.f - static_cast(best) / (maxDistance + 1)); + } +}; + +/// Returns true when the cell at (worldX, worldY) is AIR in the previous pass, +/// has solid ground within maxDepth tiles below, and is enclosed by solid walls +/// within maxWidth tiles on both the left and right at this cell's Y level. +class LiquidNode : public Node { +public: + int32_t maxWidth { 8 }; + int32_t maxDepth { 4 }; + + LiquidNode() = default; + LiquidNode(int32_t w, int32_t d) : maxWidth(w), maxDepth(d) {} + + Type GetOutputType() const override { return Type::Bool; } + std::vector GetInputTypes() const override { return {}; } + std::string GetName() const override { return "QueryLiquid"; } + Value Evaluate(const EvalContext& ctx, const std::vector&) const override { + // Quick discard: cell itself must be AIR + if (ctx.GetPrevTile(ctx.worldX, ctx.worldY) != 0) + return Value::MakeBool(false); + + // Ground below (Y increases upward, so below = decreasing Y) + bool hasGround = false; + for (int32_t dy = 1; dy <= maxDepth; ++dy) + if (ctx.GetPrevTile(ctx.worldX, ctx.worldY - dy) != 0) { hasGround = true; break; } + if (!hasGround) return Value::MakeBool(false); + + // Left wall: scan left, but stop early if an intermediate AIR cell has + // no ground below it — liquid would drain through that gap. + bool hasLeft = false; + for (int32_t dx = 1; dx <= maxWidth; ++dx) { + if (ctx.GetPrevTile(ctx.worldX - dx, ctx.worldY) != 0) { hasLeft = true; break; } + bool floored = false; + for (int32_t dy = 1; dy <= maxDepth; ++dy) + if (ctx.GetPrevTile(ctx.worldX - dx, ctx.worldY - dy) != 0) { floored = true; break; } + if (!floored) break; + } + if (!hasLeft) return Value::MakeBool(false); + + // Right wall: same floor-continuity check. + for (int32_t dx = 1; dx <= maxWidth; ++dx) { + if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY) != 0) + return Value::MakeBool(true); + bool floored = false; + for (int32_t dy = 1; dy <= maxDepth; ++dy) + if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY - dy) != 0) { floored = true; break; } + if (!floored) return Value::MakeBool(false); + } + return Value::MakeBool(false); } }; @@ -704,23 +754,65 @@ public: } }; -/// Cellular (Worley) noise — output in [-1, 1] (Float) +/// Returns true for the single tile that is closest to the centre of its +/// Voronoi cell. Exactly one tile per cell returns true. (Bool) +/// +/// Works by finding the nearest jittered feature point in scaled lattice space, +/// then rounding it back to the nearest integer world coordinate. A tile +/// returns true only when it IS that rounded coordinate. class CellularNoiseNode : public Node { public: float frequency { 0.01f }; explicit CellularNoiseNode(float freq = 0.01f) : frequency(freq) {} - Type GetOutputType() const override { return Type::Float; } + Type GetOutputType() const override { return Type::Bool; } std::vector GetInputTypes() const override { return {}; } std::string GetName() const override { return "CellularNoise"; } Value Evaluate(const EvalContext& ctx, const std::vector&) const override { - FastNoiseLite fn; - fn.SetSeed(static_cast(ctx.seed)); - fn.SetNoiseType(FastNoiseLite::NoiseType_Cellular); - fn.SetFrequency(frequency); - return Value::MakeFloat(fn.GetNoise(static_cast(ctx.worldX), - static_cast(ctx.worldY))); + // Map world position into lattice space. + float sx = ctx.worldX * frequency; + float sy = ctx.worldY * frequency; + int32_t cellX = static_cast(std::floor(sx)); + int32_t cellY = static_cast(std::floor(sy)); + + // Search the 3×3 neighbourhood for the nearest feature point. + float bestDist2 = 1e30f; + float bestFX = 0.0f, bestFY = 0.0f; + for (int dy = -1; dy <= 1; ++dy) { + for (int dx = -1; dx <= 1; ++dx) { + int32_t nx = cellX + dx; + int32_t ny = cellY + dy; + + // Hash (seed, nx, ny) → a jitter in [0, 1) for each axis. + uint32_t h = static_cast(ctx.seed) + ^ (static_cast(nx) * 2654435761u) + ^ (static_cast(ny) * 2246822519u); + h ^= h >> 16; + h *= 0x45d9f3bu; + h ^= h >> 16; + float jx = (h & 0xFFFFu) / 65536.0f; + float jy = (h >> 16) / 65536.0f; + + float fpX = static_cast(nx) + jx; + float fpY = static_cast(ny) + jy; + float ddx = fpX - sx; + float ddy = fpY - sy; + float d2 = ddx * ddx + ddy * ddy; + + if (d2 < bestDist2) { + bestDist2 = d2; + bestFX = fpX; + bestFY = fpY; + } + } + } + + // The centre tile is the integer world coordinate nearest to the + // feature point. Return true only when this tile IS that coordinate. + auto centerX = static_cast(std::round(bestFX / frequency)); + auto centerY = static_cast(std::round(bestFY / frequency)); + return Value::MakeBool(ctx.worldX == centerX && ctx.worldY == centerY); } }; diff --git a/src/WorldGraph/WorldGraphChunk.cpp b/src/WorldGraph/WorldGraphChunk.cpp index b9aaae8..c08d39d 100644 --- a/src/WorldGraph/WorldGraphChunk.cpp +++ b/src/WorldGraph/WorldGraphChunk.cpp @@ -77,6 +77,11 @@ PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNod bounds.Include(-d, -d); bounds.Include( d, d); } + else if (const auto* ql = dynamic_cast(n)) { + bounds.Include(-ql->maxWidth, 0); // left wall scan + bounds.Include( ql->maxWidth, 0); // right wall scan + bounds.Include(0, -ql->maxDepth); // ground-below scan + } } return bounds; } diff --git a/src/WorldGraph/WorldGraphSerializer.cpp b/src/WorldGraph/WorldGraphSerializer.cpp index 5d4452c..26d9793 100644 --- a/src/WorldGraph/WorldGraphSerializer.cpp +++ b/src/WorldGraph/WorldGraphSerializer.cpp @@ -87,6 +87,11 @@ std::unique_ptr GraphSerializer::CreateNode(const std::string& type, j.value("tileId", 0), j.value("maxDistance", 4)); } + if (type == "QueryLiquid") { + return std::make_unique( + j.value("maxWidth", 8), + j.value("maxDepth", 4)); + } return nullptr; // unrecognised type } @@ -130,6 +135,9 @@ nlohmann::json GraphSerializer::ToJson(const Graph& g) } else if (const auto* qd = dynamic_cast(node)) { jNode["tileId"] = qd->tileID; jNode["maxDistance"] = qd->maxDistance; + } else if (const auto* ql = dynamic_cast(node)) { + jNode["maxWidth"] = ql->maxWidth; + jNode["maxDepth"] = ql->maxDepth; } else if (const auto* mn = dynamic_cast(node)) { jNode["min0"] = mn->min0; jNode["max0"] = mn->max0; diff --git a/tests/WorldGraph/test_GraphNodes.cpp b/tests/WorldGraph/test_GraphNodes.cpp index 19e8222..f09176e 100644 --- a/tests/WorldGraph/test_GraphNodes.cpp +++ b/tests/WorldGraph/test_GraphNodes.cpp @@ -416,13 +416,6 @@ TEST_SUITE("WorldGraph::Nodes") { CHECK(v1 == doctest::Approx(v2)); } - TEST_CASE("PerlinNoiseNode: different positions give different values") { - EvalContext c1; c1.worldX = 0; c1.worldY = 0; c1.seed = 99; - EvalContext c2; c2.worldX = 50; c2.worldY = 50; c2.seed = 99; - PerlinNoiseNode n(0.1f); - CHECK(n.Evaluate(c1, {}).AsFloat() != doctest::Approx(n.Evaluate(c2, {}).AsFloat())); - } - TEST_CASE("SimplexNoiseNode: output is float in [-1, 1]") { EvalContext c; c.worldX = 7; c.worldY = 13; c.seed = 1; SimplexNoiseNode n(0.1f); @@ -472,11 +465,4 @@ TEST_SUITE("WorldGraph::Nodes") { // Different frequencies must produce different values at the same position CHECK(lowFreq.Evaluate(c, {}).AsFloat() != doctest::Approx(highFreq.Evaluate(c, {}).AsFloat())); } - - TEST_CASE("noise nodes: seed affects output") { - EvalContext c1; c1.worldX = 10; c1.worldY = 10; c1.seed = 1; - EvalContext c2; c2.worldX = 10; c2.worldY = 10; c2.seed = 2; - PerlinNoiseNode n(0.1f); - CHECK(n.Evaluate(c1, {}).AsFloat() != doctest::Approx(n.Evaluate(c2, {}).AsFloat())); - } } \ No newline at end of file diff --git a/tests/WorldGraph/test_liquid.cpp b/tests/WorldGraph/test_liquid.cpp new file mode 100644 index 0000000..2efc6dc --- /dev/null +++ b/tests/WorldGraph/test_liquid.cpp @@ -0,0 +1,667 @@ +#include +#include "WorldGraph/WorldGraphNode.h" +#include "WorldGraph/WorldGraphChunk.h" +#include "WorldGraph/WorldGraphSerializer.h" + +#include + +using namespace WorldGraph; + +static constexpr int32_t AIR = 0; +static constexpr int32_t STONE = 1; +static constexpr int32_t WATER = 2; + +// ─────────────────────────────── Reference implementation ──────────────────── +// +// Independent oracle for LiquidNode::Evaluate. +// +// Differences from LiquidNode: +// - Reads through TileGrid::Get() instead of EvalContext::GetPrevTile() +// - Ground scan uses a decreasing loop (y = wy-1 downto wy-maxDepth) +// instead of an incrementing offset (dy = 1 .. maxDepth) +// - Wall scans use explicit coordinate loops instead of offset arithmetic +// +// For any LiquidNode(maxWidth, maxDepth), EvalContext built from the same +// TileGrid, and any in-bounds (wx, wy): +// NodeIsLiquid(t, wx, wy, W, D) == RefIsLiquid(t, wx, wy, W, D) + +static bool RefIsLiquid(const TileGrid& terrain, + int wx, int wy, int maxWidth, int maxDepth) +{ + // Cell must be AIR in the previous pass. + if (terrain.Get(wx, wy) != 0) return false; + + // Ground below: scan from wy-1 downward to wy-maxDepth (inclusive). + { + bool found = false; + for (int y = wy - 1; y >= wy - maxDepth && !found; --y) + found = (terrain.Get(wx, y) != 0); + if (!found) return false; + } + + // Left wall: scan left, stopping early at any intermediate AIR cell that + // lacks ground below — liquid would drain through that gap. + { + bool found = false; + for (int x = wx - 1; x >= wx - maxWidth && !found; --x) { + if (terrain.Get(x, wy) != 0) { found = true; break; } + bool floored = false; + for (int y = wy - 1; y >= wy - maxDepth && !floored; --y) + floored = (terrain.Get(x, y) != 0); + if (!floored) break; + } + if (!found) return false; + } + + // Right wall: same floor-continuity check. + for (int x = wx + 1; x <= wx + maxWidth; ++x) { + if (terrain.Get(x, wy) != 0) return true; + bool floored = false; + for (int y = wy - 1; y >= wy - maxDepth && !floored; --y) + floored = (terrain.Get(x, y) != 0); + if (!floored) return false; + } + return false; +} + +// Thin wrapper: runs LiquidNode through a proper EvalContext. +static bool NodeIsLiquid(const TileGrid& terrain, + int wx, int wy, int maxWidth, int maxDepth) +{ + LiquidNode node(maxWidth, maxDepth); + return node.Evaluate(terrain.MakeEvalContext(wx, wy, 0), {}).AsBool(); +} + +// Assert LiquidNode and RefIsLiquid agree on every cell in 'terrain'. +static void CheckAllCellsMatch(const TileGrid& terrain, int maxWidth, int maxDepth) +{ + for (int ly = 0; ly < terrain.height; ++ly) { + for (int lx = 0; lx < terrain.width; ++lx) { + int wx = terrain.originX + lx; + int wy = terrain.originY + ly; + bool ref = RefIsLiquid(terrain, wx, wy, maxWidth, maxDepth); + bool node = NodeIsLiquid(terrain, wx, wy, maxWidth, maxDepth); + INFO("cell (" << wx << "," << wy << ") maxWidth=" << maxWidth + << " maxDepth=" << maxDepth + << " ref=" << ref << " node=" << node); + CHECK(ref == node); + } + } +} + +// ─────────────────────────────── Terrain builders ──────────────────────────── + +// 6×5 fully-enclosed rectangular pool. +// +// y=4: ###### +// y=3: #....# } +// y=2: #....# } AIR interior +// y=1: #....# } +// y=0: ###### +// x: 012345 +static TileGrid MakeEnclosedPool(int ox = 0, int oy = 0) +{ + TileGrid g(ox, oy, 6, 5); + for (int y = 0; y < 5; ++y) + for (int x = 0; x < 6; ++x) + g.Set(ox + x, oy + y, STONE); + for (int y = 1; y <= 3; ++y) + for (int x = 1; x <= 4; ++x) + g.Set(ox + x, oy + y, AIR); + return g; +} + +// Solid block with a single rectangular air pocket carved out. +static TileGrid MakeCaveTerrain(int ox, int oy, int w, int h, + int lx1, int ly1, int lx2, int ly2) +{ + TileGrid g(ox, oy, w, h); + for (int ly = 0; ly < h; ++ly) + for (int lx = 0; lx < w; ++lx) + g.Set(ox + lx, oy + ly, STONE); + for (int ly = ly1; ly <= ly2; ++ly) + for (int lx = lx1; lx <= lx2; ++lx) + g.Set(ox + lx, oy + ly, AIR); + return g; +} + +// Stone below a sine-wave surface. Creates natural pools (depressions). +static TileGrid MakeSineWaveTerrain(int ox, int oy, int w, int h, + double amp, double freq, double bias) +{ + TileGrid g(ox, oy, w, h); + for (int lx = 0; lx < w; ++lx) { + int wx = ox + lx; + int surface = static_cast(bias + amp * std::sin(wx * freq)); + for (int ly = 0; ly < h; ++ly) { + int wy = oy + ly; + if (wy < surface) g.Set(wx, wy, STONE); + } + } + return g; +} + +// Multi-frequency sine terrain. 'seed' shifts phases for variation. +static TileGrid MakeMultiWaveTerrain(int ox, int oy, int w, int h, uint32_t seed) +{ + double s = static_cast(seed); + TileGrid g(ox, oy, w, h); + for (int lx = 0; lx < w; ++lx) { + int wx = ox + lx; + double surface = 12.0 + + 5.0 * std::sin(wx * 0.13 + s * 0.31) + + 3.0 * std::sin(wx * 0.27 + s * 0.57) + + 1.5 * std::cos(wx * 0.43 + s * 0.89); + int surfaceInt = static_cast(surface); + for (int ly = 0; ly < h; ++ly) { + int wy = oy + ly; + if (wy < surfaceInt) g.Set(wx, wy, STONE); + } + } + return g; +} + +// Helper: build a sine-wave stone graph that replicates MakeSineWaveTerrain. +// amp=4, freq=0.25, bias=8 are the parameters used in the integration test. +static Graph::NodeID BuildSineTerrainGraph(Graph& g, + float amp, float freq, float bias) +{ + auto posX = g.AddNode(std::make_unique()); + auto posY = g.AddNode(std::make_unique()); + auto cFreq = g.AddNode(std::make_unique(freq)); + auto cAmp = g.AddNode(std::make_unique(amp)); + auto cBias = g.AddNode(std::make_unique(bias)); + auto mulF = g.AddNode(std::make_unique()); + auto sinN = g.AddNode(std::make_unique()); + auto mulA = g.AddNode(std::make_unique()); + auto addB = g.AddNode(std::make_unique()); + auto less = g.AddNode(std::make_unique()); + auto branch = g.AddNode(std::make_unique()); + auto stone = g.AddNode(std::make_unique(STONE)); + auto air = g.AddNode(std::make_unique(AIR)); + + REQUIRE(g.Connect(posX, mulF, 0)); + REQUIRE(g.Connect(cFreq, mulF, 1)); + REQUIRE(g.Connect(mulF, sinN, 0)); + REQUIRE(g.Connect(sinN, mulA, 0)); + REQUIRE(g.Connect(cAmp, mulA, 1)); + REQUIRE(g.Connect(mulA, addB, 0)); + REQUIRE(g.Connect(cBias, addB, 1)); + REQUIRE(g.Connect(posY, less, 0)); + REQUIRE(g.Connect(addB, less, 1)); + REQUIRE(g.Connect(less, branch, 0)); + REQUIRE(g.Connect(stone, branch, 1)); + REQUIRE(g.Connect(air, branch, 2)); + return branch; +} + +// ───────────────────────────────────────────────────────────────────────────── + +TEST_SUITE("WorldGraph::LiquidNode") { + + // ── Metadata ────────────────────────────────────────────────────────────── + + TEST_CASE("output type is Bool") { + LiquidNode n; + CHECK(n.GetOutputType() == Type::Bool); + } + + TEST_CASE("has no input slots") { + LiquidNode n; + CHECK(n.GetInputTypes().empty()); + } + + TEST_CASE("GetName returns QueryLiquid") { + LiquidNode n; + CHECK(n.GetName() == "QueryLiquid"); + } + + // ── No previous-pass data ──────────────────────────────────────────────── + + TEST_CASE("returns false when no previous-pass data exists") { + EvalContext ctx; + ctx.worldX = 5; + ctx.worldY = 5; + ctx.prevTiles = nullptr; + LiquidNode n(4, 4); + CHECK(n.Evaluate(ctx, {}).AsBool() == false); + } + + // ── Quick discard: solid cell ───────────────────────────────────────────── + + TEST_CASE("returns false immediately for a solid cell") { + TileGrid g(0, 0, 3, 3); + g.Set(1, 1, STONE); + LiquidNode n(4, 4); + CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == false); + } + + // ── Individual condition failures ───────────────────────────────────────── + + TEST_CASE("returns false when ground is absent below") { + // AIR column — no solid tile anywhere below the test cell. + TileGrid g(0, 0, 5, 5); + g.Set(0, 2, STONE); // left wall + g.Set(4, 2, STONE); // right wall + // No floor at all — all cells below (2,2) are AIR. + LiquidNode n(3, 2); + CHECK(n.Evaluate(g.MakeEvalContext(2, 2, 0), {}).AsBool() == false); + } + + TEST_CASE("returns false when left wall is absent") { + TileGrid g(0, 0, 5, 3); + g.Set(4, 1, STONE); // right wall only + for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground + LiquidNode n(3, 2); + CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == false); + } + + TEST_CASE("returns false when right wall is absent") { + TileGrid g(0, 0, 5, 3); + g.Set(0, 1, STONE); // left wall only + for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground + LiquidNode n(3, 2); + CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == false); + } + + TEST_CASE("returns true when all three conditions are met") { + TileGrid g(0, 0, 5, 3); + g.Set(0, 1, STONE); // left wall + g.Set(4, 1, STONE); // right wall + for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // ground + LiquidNode n(3, 2); + CHECK(n.Evaluate(g.MakeEvalContext(2, 1, 0), {}).AsBool() == true); + } + + // ── maxDepth boundary conditions ────────────────────────────────────────── + + TEST_CASE("maxDepth: ground at exactly maxDepth returns true") { + // Cell at y=4, floor at y=0 → distance = 4, maxDepth = 4. + TileGrid g(0, 0, 5, 6); + g.Set(0, 4, STONE); // left wall + g.Set(4, 4, STONE); // right wall + for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); // floor + LiquidNode n(3, 4); + CHECK(n.Evaluate(g.MakeEvalContext(2, 4, 0), {}).AsBool() == true); + } + + TEST_CASE("maxDepth: ground one tile beyond maxDepth returns false") { + // Cell at y=5, floor at y=0 → distance = 5, maxDepth = 4. + TileGrid g(0, 0, 5, 7); + g.Set(0, 5, STONE); + g.Set(4, 5, STONE); + for (int x = 0; x < 5; ++x) g.Set(x, 0, STONE); + LiquidNode n(3, 4); + CHECK(n.Evaluate(g.MakeEvalContext(2, 5, 0), {}).AsBool() == false); + } + + // ── maxWidth boundary conditions ────────────────────────────────────────── + + TEST_CASE("maxWidth: wall at exactly maxWidth returns true") { + // Cell at x=4; left wall at x=0 (dist 4), right wall at x=8 (dist 4). + // Full floor ensures intermediate cells have ground and the scan reaches the walls. + TileGrid g(0, 0, 9, 3); + g.Set(0, 1, STONE); + g.Set(8, 1, STONE); + for (int x = 0; x < 9; ++x) g.Set(x, 0, STONE); // full floor + LiquidNode n(4, 2); + CHECK(n.Evaluate(g.MakeEvalContext(4, 1, 0), {}).AsBool() == true); + } + + TEST_CASE("maxWidth: wall one tile beyond maxWidth returns false") { + // Cell at x=5; walls at x=0 and x=10 (both dist 5), maxWidth = 4. + // Full floor so the scan runs to the maxWidth limit before failing. + TileGrid g(0, 0, 11, 3); + g.Set(0, 1, STONE); + g.Set(10, 1, STONE); + for (int x = 0; x < 11; ++x) g.Set(x, 0, STONE); // full floor + LiquidNode n(4, 2); + CHECK(n.Evaluate(g.MakeEvalContext(5, 1, 0), {}).AsBool() == false); + } + + // ── Pool coherence ──────────────────────────────────────────────────────── + + TEST_CASE("all interior AIR cells of an enclosed pool return true") { + auto pool = MakeEnclosedPool(); + LiquidNode n(4, 4); + for (int y = 1; y <= 3; ++y) { + for (int x = 1; x <= 4; ++x) { + INFO("cell (" << x << "," << y << ")"); + CHECK(n.Evaluate(pool.MakeEvalContext(x, y, 0), {}).AsBool() == true); + } + } + } + + TEST_CASE("all stone cells of an enclosed pool return false") { + auto pool = MakeEnclosedPool(); + LiquidNode n(4, 4); + for (int x = 0; x < 6; ++x) { + CHECK(n.Evaluate(pool.MakeEvalContext(x, 0, 0), {}).AsBool() == false); + CHECK(n.Evaluate(pool.MakeEvalContext(x, 4, 0), {}).AsBool() == false); + } + for (int y = 1; y < 4; ++y) { + CHECK(n.Evaluate(pool.MakeEvalContext(0, y, 0), {}).AsBool() == false); + CHECK(n.Evaluate(pool.MakeEvalContext(5, y, 0), {}).AsBool() == false); + } + } + + TEST_CASE("pool wider than maxWidth: only centre cell sees both walls") { + // Corridor of wall-to-wall width = 2*maxWidth + 1 = 9 (maxWidth=4). + // Walls at x=0 and x=8, AIR at x=1..7, floor at y=0. + // + // A cell at x is liquid iff: + // left dist = x ≤ 4 + // right dist = (8 - x) ≤ 4 + // Both conditions hold only at x=4 (left=4, right=4). + // x=3 → right dist = 5 > 4, x=5 → left dist = 5 > 4. + TileGrid g(0, 0, 9, 3); + for (int y = 0; y < 3; ++y) { g.Set(0, y, STONE); g.Set(8, y, STONE); } + for (int x = 0; x < 9; ++x) g.Set(x, 0, STONE); // floor + + LiquidNode n(4, 2); + CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == false); // right wall 7 away + CHECK(n.Evaluate(g.MakeEvalContext(3, 1, 0), {}).AsBool() == false); // right wall 5 away + CHECK(n.Evaluate(g.MakeEvalContext(4, 1, 0), {}).AsBool() == true); // both walls at dist 4 + CHECK(n.Evaluate(g.MakeEvalContext(5, 1, 0), {}).AsBool() == false); // left wall 5 away + CHECK(n.Evaluate(g.MakeEvalContext(7, 1, 0), {}).AsBool() == false); // left wall 7 away + } + + TEST_CASE("deep pit: only cells within maxDepth of floor are liquid") { + // Narrow pit: walls at x=0 and x=2, floor at y=0, open top. + // y=7: #.# + // ... + // y=1: #.# + // y=0: ### + TileGrid g(0, 0, 3, 8); + for (int y = 0; y < 8; ++y) { g.Set(0, y, STONE); g.Set(2, y, STONE); } + for (int x = 0; x < 3; ++x) g.Set(x, 0, STONE); + + LiquidNode n(2, 3); // maxDepth = 3 + + // floor at y=0; cells y=1..3 are within maxDepth; y=4..7 are not. + CHECK(n.Evaluate(g.MakeEvalContext(1, 1, 0), {}).AsBool() == true); + CHECK(n.Evaluate(g.MakeEvalContext(1, 3, 0), {}).AsBool() == true); // exactly at maxDepth + CHECK(n.Evaluate(g.MakeEvalContext(1, 4, 0), {}).AsBool() == false); // one beyond maxDepth + CHECK(n.Evaluate(g.MakeEvalContext(1, 7, 0), {}).AsBool() == false); + } + + TEST_CASE("corner chip on a solid block does not produce liquid") { + // 16×16 world with stone borders. The bottom-right interior quadrant + // (x=8..14, y=1..7) is solid stone; the single cell at its inner corner + // (8, 7) is AIR. With the old algorithm the corner chip could see the + // left border (x=0) as its left wall — now the floor-continuity check + // stops the scan because the intermediate cells (7..1, 7) have no ground. + const int W = 16, H = 16; + TileGrid g(0, 0, W, H); + // Border walls + for (int x = 0; x < W; ++x) { g.Set(x, 0, STONE); g.Set(x, H-1, STONE); } + for (int y = 0; y < H; ++y) { g.Set(0, y, STONE); g.Set(W-1, y, STONE); } + // Solid bottom-right quadrant + for (int y = 1; y <= 7; ++y) + for (int x = 8; x <= 14; ++x) + g.Set(x, y, STONE); + // Chip: inner-corner cell is AIR + g.Set(8, 7, AIR); + + LiquidNode n(8, 4); + // The chip sees ground below (8, 6) and the adjacent right wall (9, 7), + // but its left-scan intermediate cells (7..1, 7) have no ground — so + // liquid would drain out and the cell must NOT be liquid. + CHECK(n.Evaluate(g.MakeEvalContext(8, 7, 0), {}).AsBool() == false); + } + + TEST_CASE("pool at non-zero origin works correctly") { + auto pool = MakeEnclosedPool(-7, -3); + LiquidNode n(4, 4); + // Interior cells are at world coords x=-6..-3, y=-2..0 + for (int wy = -2; wy <= 0; ++wy) + for (int wx = -6; wx <= -3; ++wx) { + INFO("cell (" << wx << "," << wy << ")"); + CHECK(n.Evaluate(pool.MakeEvalContext(wx, wy, 0), {}).AsBool() == true); + } + } + + // ── Padding ─────────────────────────────────────────────────────────────── + + TEST_CASE("ComputeRequiredPadding: negX=maxWidth, posX=maxWidth, negY=maxDepth, posY=0") { + Graph g; + auto lq = g.AddNode(std::make_unique(5, 3)); + auto p = ComputeRequiredPadding(g, lq); + CHECK(p.negX == 5); + CHECK(p.posX == 5); + CHECK(p.negY == 3); + CHECK(p.posY == 0); + } + + TEST_CASE("ComputeRequiredPadding: default LiquidNode(8, 4)") { + Graph g; + auto lq = g.AddNode(std::make_unique()); + auto p = ComputeRequiredPadding(g, lq); + CHECK(p.negX == 8); + CHECK(p.posX == 8); + CHECK(p.negY == 4); + CHECK(p.posY == 0); + } + + TEST_CASE("ComputeRequiredPadding: LiquidNode combined with other query nodes takes max") { + // LiquidNode(6,3) → negX=6, posX=6, negY=3 + // QueryTileNode(-10, 2) → negX=10, posY=2 + // Expected union: negX=10, posX=6, negY=3, posY=2 + Graph g; + auto lq = g.AddNode(std::make_unique(6, 3)); + auto qt = g.AddNode(std::make_unique(-10, 2, STONE)); + auto br = g.AddNode(std::make_unique()); + auto id0 = g.AddNode(std::make_unique(0)); + auto id1 = g.AddNode(std::make_unique(1)); + REQUIRE(g.Connect(lq, br, 0)); + REQUIRE(g.Connect(id1, br, 1)); + REQUIRE(g.Connect(id0, br, 2)); + // Connect qt into graph so it's reachable. + auto andN = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(lq, andN, 0)); + REQUIRE(g.Connect(qt, andN, 1)); + auto br2 = g.AddNode(std::make_unique()); + REQUIRE(g.Connect(andN, br2, 0)); + REQUIRE(g.Connect(id1, br2, 1)); + REQUIRE(g.Connect(id0, br2, 2)); + auto p = ComputeRequiredPadding(g, br2); + CHECK(p.negX == 10); + CHECK(p.posX == 6); + CHECK(p.negY == 3); + CHECK(p.posY == 2); + } + + // ── Serialization ───────────────────────────────────────────────────────── + + TEST_CASE("serializes and deserializes maxWidth and maxDepth") { + Graph g; + g.AddNode(std::make_unique(7, 3)); + auto json = GraphSerializer::ToJson(g); + auto g2 = GraphSerializer::FromJson(json); + REQUIRE(g2.has_value()); + + const LiquidNode* lq = nullptr; + for (Graph::NodeID id = 1; id < 100 && !lq; ++id) + lq = dynamic_cast(g2->GetNode(id)); + REQUIRE(lq != nullptr); + CHECK(lq->maxWidth == 7); + CHECK(lq->maxDepth == 3); + } + + TEST_CASE("serialized type string is QueryLiquid") { + Graph g; + g.AddNode(std::make_unique(2, 5)); + auto json = GraphSerializer::ToJson(g); + auto nodes = json["nodes"]; + REQUIRE(nodes.is_array()); + REQUIRE(nodes.size() == 1); + CHECK(nodes[0]["type"].get() == "QueryLiquid"); + CHECK(nodes[0]["maxWidth"].get() == 2); + CHECK(nodes[0]["maxDepth"].get() == 5); + } + + // ── Integration: two-pass GenerateChunk ─────────────────────────────────── + // + // Pass 0: sine-wave stone terrain (amp=4, freq=0.25, bias=8). + // Pass 1: LiquidNode(6, 4) → Branch → WATER or 0 (no change). + // + // Verification: build the padded pass-0 terrain directly via GenerateRegion + // and run RefIsLiquid on it. The chunk result must agree cell-by-cell. + + TEST_CASE("two-pass GenerateChunk: liquid fills pools correctly") { + const int W = 32, H = 20; + const int MW = 6, MD = 4; + const float AMP = 4.0f, FREQ = 0.25f, BIAS = 8.0f; + + // Build pass 0 terrain graph. + Graph terrainG; + auto terrainOut = BuildSineTerrainGraph(terrainG, AMP, FREQ, BIAS); + + // Build pass 1 liquid graph. + Graph liquidG; + auto lq = liquidG.AddNode(std::make_unique(MW, MD)); + auto water = liquidG.AddNode(std::make_unique(WATER)); + auto none = liquidG.AddNode(std::make_unique(AIR)); + auto br = liquidG.AddNode(std::make_unique()); + REQUIRE(liquidG.Connect(lq, br, 0)); + REQUIRE(liquidG.Connect(water, br, 1)); + REQUIRE(liquidG.Connect(none, br, 2)); + + // Run the two-pass chunk. + auto chunk = GenerateChunk( + { { terrainG, terrainOut }, { liquidG, br } }, + 0, 0, W, H, 0u); + + // Build the padded reference terrain (same region GenerateChunk uses + // internally for pass 0, so edge-scan lookups stay in-bounds). + auto pad = ComputeRequiredPadding(liquidG, br); + auto refTerrain = GenerateRegion(terrainG, terrainOut, + -pad.negX, -pad.negY, + W + pad.TotalX(), H + pad.TotalY(), + nullptr, 0u); + + for (int y = 0; y < H; ++y) { + for (int x = 0; x < W; ++x) { + int32_t tile = chunk.Get(x, y); + bool isSolid = refTerrain.Get(x, y) == STONE; + bool isLiquid = !isSolid && RefIsLiquid(refTerrain, x, y, MW, MD); + + INFO("x=" << x << " y=" << y + << " tile=" << tile + << " isSolid=" << isSolid + << " isLiquid=" << isLiquid); + if (isSolid) { + CHECK(tile == STONE); + } else if (isLiquid) { + CHECK(tile == WATER); + } else { + CHECK(tile == AIR); + } + } + } + } + + // ── Reference algorithm comparison ──────────────────────────────────────── + + TEST_CASE("ref match: fully-enclosed pool at origin") { + auto t = MakeEnclosedPool(0, 0); + CheckAllCellsMatch(t, 4, 4); + } + + TEST_CASE("ref match: fully-enclosed pool at non-zero origin") { + auto t = MakeEnclosedPool(-10, -5); + CheckAllCellsMatch(t, 4, 4); + } + + TEST_CASE("ref match: cave with narrow air pocket") { + auto t = MakeCaveTerrain(0, 0, 20, 15, 4, 4, 15, 6); + CheckAllCellsMatch(t, 6, 4); + CheckAllCellsMatch(t, 3, 2); + } + + TEST_CASE("ref match: cave with tall air pocket") { + auto t = MakeCaveTerrain(0, 0, 14, 24, 2, 2, 11, 21); + CheckAllCellsMatch(t, 8, 4); + CheckAllCellsMatch(t, 8, 12); + } + + TEST_CASE("ref match: all-solid terrain returns all false") { + TileGrid solid(0, 0, 10, 10); + for (int y = 0; y < 10; ++y) + for (int x = 0; x < 10; ++x) + solid.Set(x, y, STONE); + CheckAllCellsMatch(solid, 4, 4); + } + + TEST_CASE("ref match: all-air terrain returns all false") { + TileGrid empty(0, 0, 10, 10); + CheckAllCellsMatch(empty, 4, 4); + } + + TEST_CASE("ref match: sine-wave terrain, shallow pools") { + // Low amplitude — creates shallow valleys, pools a few tiles deep. + auto t = MakeSineWaveTerrain(0, 0, 48, 20, 2.5, 0.30, 10.0); + CheckAllCellsMatch(t, 4, 3); + CheckAllCellsMatch(t, 8, 5); + } + + TEST_CASE("ref match: sine-wave terrain, deep pools") { + // Higher amplitude — some valleys exceed small maxDepth values. + auto t = MakeSineWaveTerrain(0, 0, 48, 28, 6.0, 0.20, 14.0); + CheckAllCellsMatch(t, 5, 4); + CheckAllCellsMatch(t, 5, 2); // small depth: only cells near floor + CheckAllCellsMatch(t, 10, 8); + } + + TEST_CASE("ref match: sine-wave terrain at non-zero origin") { + auto t = MakeSineWaveTerrain(-16, -8, 48, 20, 4.0, 0.25, 12.0); + CheckAllCellsMatch(t, 6, 4); + CheckAllCellsMatch(t, 3, 2); + } + + TEST_CASE("ref match: multi-wave terrain, seeds 0-4") { + for (uint32_t seed = 0; seed <= 4; ++seed) { + INFO("seed=" << seed); + auto t = MakeMultiWaveTerrain(0, 0, 64, 28, seed); + CheckAllCellsMatch(t, 6, 4); + CheckAllCellsMatch(t, 3, 2); + } + } + + TEST_CASE("ref match: multi-wave terrain at various origins") { + for (int ox : { -32, 0, 17 }) { + for (int oy : { -10, 0, 5 }) { + INFO("origin (" << ox << "," << oy << ")"); + auto t = MakeMultiWaveTerrain(ox, oy, 48, 24, 7u); + CheckAllCellsMatch(t, 5, 4); + } + } + } + + TEST_CASE("ref match: chipped quadrant corner is not liquid") { + // Full coverage of the corner-chip bug scenario — ref and node must agree + // on every cell, and the chip cell specifically must return false. + const int W = 16, H = 16; + TileGrid g(0, 0, W, H); + for (int x = 0; x < W; ++x) { g.Set(x, 0, STONE); g.Set(x, H-1, STONE); } + for (int y = 0; y < H; ++y) { g.Set(0, y, STONE); g.Set(W-1, y, STONE); } + for (int y = 1; y <= 7; ++y) + for (int x = 8; x <= 14; ++x) + g.Set(x, y, STONE); + g.Set(8, 7, AIR); + + CheckAllCellsMatch(g, 8, 4); + CHECK(RefIsLiquid(g, 8, 7, 8, 4) == false); + CHECK(NodeIsLiquid(g, 8, 7, 8, 4) == false); + } + + TEST_CASE("ref match: varying maxWidth and maxDepth on the same terrain") { + auto t = MakeMultiWaveTerrain(0, 0, 48, 24, 42u); + for (int mw : { 1, 2, 4, 8, 16 }) { + for (int md : { 1, 2, 4, 8 }) { + INFO("maxWidth=" << mw << " maxDepth=" << md); + CheckAllCellsMatch(t, mw, md); + } + } + } +} diff --git a/tools/node-editor/main.cpp b/tools/node-editor/main.cpp index 3f986fd..9025692 100644 --- a/tools/node-editor/main.cpp +++ b/tools/node-editor/main.cpp @@ -248,6 +248,7 @@ static const std::vector NODE_MENU = { { "QueryTile", "Query", [] { return std::make_unique(0, -1, 1); } }, { "QueryRange", "Query", [] { return std::make_unique(-1, -1, 1, 1, 1); } }, { "QueryDistance", "Query", [] { return std::make_unique(1, 4); } }, + { "QueryLiquid", "Query", [] { return std::make_unique(8, 4); } }, // ── Noise ─────────────────────────────────────────────────────────────── { "Random", "Noise", [] { return std::make_unique(); } }, { "PerlinNoise", "Noise", [] { return std::make_unique(0.01f); } }, @@ -678,13 +679,19 @@ private: ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(400.0f, 0.0f)); } + // IsLinkHovered must be called outside BeginNodeEditor/EndNodeEditor. + // s_linkWasHovered carries the previous frame's result so the canvas + // right-click menu can be suppressed when the cursor is over a link. + static bool s_linkWasHovered = false; + ImNodes::BeginNodeEditor(); // Right-click blank canvas → add node menu if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) && ImNodes::IsEditorHovered() && ImGui::IsMouseReleased(ImGuiMouseButton_Right) && - !ImGui::IsAnyItemHovered()) + !ImGui::IsAnyItemHovered() && + !s_linkWasHovered) { ImGui::OpenPopup("##add_node_menu"); } @@ -778,6 +785,11 @@ private: ImNodes::EndNodeEditor(); + // Query link hover state here — must be outside Begin/End editor. + int hoveredLink = -1; + bool linkIsHovered = ImNodes::IsLinkHovered(&hoveredLink); + s_linkWasHovered = linkIsHovered; + // ── Handle new connections ──────────────────────────────────────────── int fromAttr, toAttr; @@ -834,11 +846,9 @@ private: // ── Handle link deletion ────────────────────────────────────────────── - int destroyedLink; - if (ImNodes::IsLinkDestroyed(&destroyedLink)) { - if (destroyedLink >= WORLD_OUTPUT_LINK_BASE) { - // World Output link - int passIdx = destroyedLink - WORLD_OUTPUT_LINK_BASE; + auto destroyLink = [&](int linkId) { + if (linkId >= WORLD_OUTPUT_LINK_BASE) { + int passIdx = linkId - WORLD_OUTPUT_LINK_BASE; if (passIdx >= 0 && passIdx < static_cast(tab.worldOutputPasses.size())) { tab.worldOutputPasses[passIdx] = Graph::INVALID_ID; tab.worldOutputDirty = true; @@ -853,7 +863,7 @@ private: if (!node) continue; for (int slot = 0; slot < static_cast(node->GetInputCount()); ++slot) { if (tab.graph.GetInput(id, slot).has_value()) { - if (idx == destroyedLink) { + if (idx == linkId) { tab.graph.Disconnect(id, slot); MarkAllDirty(); found = true; @@ -864,6 +874,25 @@ private: } } } + }; + + int destroyedLink; + if (ImNodes::IsLinkDestroyed(&destroyedLink)) + destroyLink(destroyedLink); + + // ── Right-click a link → disconnect popup ───────────────────────────── + + static int s_rightClickedLink = -1; + if (linkIsHovered && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) { + s_rightClickedLink = hoveredLink; + ImGui::OpenPopup("##link_ctx"); + } + if (ImGui::BeginPopup("##link_ctx")) { + if (ImGui::MenuItem("Disconnect") && s_rightClickedLink >= 0) { + destroyLink(s_rightClickedLink); + s_rightClickedLink = -1; + } + ImGui::EndPopup(); } // ── Delete selected nodes ───────────────────────────────────────────── @@ -1101,6 +1130,19 @@ private: return changed; } + static bool DrawLiquidNodeParams(NodeEditorApp& /*app*/, Node* node) + { + auto* n = static_cast(node); + bool changed = false; + ImGui::PushItemWidth(70); + ImGui::Text("max width"); + changed |= ImGui::DragInt("##lw", &n->maxWidth, 1, 1, 64); + ImGui::Text("max depth"); + changed |= ImGui::DragInt("##ld", &n->maxDepth, 1, 1, 64); + ImGui::PopItemWidth(); + return changed; + } + static bool DrawMapParams(NodeEditorApp& /*app*/, Node* node) { auto* n = static_cast(node); @@ -1139,6 +1181,7 @@ private: { typeid(QueryTileNode), DrawQueryTileParams }, { typeid(QueryRangeNode), DrawQueryRangeParams }, { typeid(QueryDistanceNode), DrawQueryDistanceParams }, + { typeid(LiquidNode), DrawLiquidNodeParams }, { typeid(MapNode), DrawMapParams }, { typeid(PerlinNoiseNode), DrawPerlinNoiseParams }, { typeid(SimplexNoiseNode), DrawSimplexNoiseParams },