This commit is contained in:
Connor
2026-02-24 17:22:41 +09:00
parent 1b7fd1c7f8
commit bee5aa0e8f
8 changed files with 1113 additions and 123 deletions

View File

@@ -30,52 +30,28 @@
76.0 76.0
], ],
"21": [ "21": [
-380.255615234375, -786.255615234375,
459.421142578125 714.421142578125
], ],
"23": [ "23": [
-154.0, -560.0,
248.0 503.0
], ],
"24": [ "24": [
175.0, -231.0,
331.0 586.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
], ],
"31": [ "31": [
-7.0, -413.0,
389.0 644.0
], ],
"32": [ "32": [
-588.0, -964.0,
597.0 877.0
], ],
"33": [ "33": [
-151.0, -557.0,
497.0 752.0
], ],
"34": [ "34": [
-1580.6732177734375, -1580.6732177734375,
@@ -121,25 +97,101 @@
-799.0, -799.0,
33.0 33.0
], ],
"47": [
-249.0374755859375,
282.7857666015625
],
"48": [
4.9625244140625,
268.7857666015625
],
"49": [
-120.0374755859375,
356.7857666015625
],
"5": [ "5": [
-122.0, -122.0,
-513.0 -513.0
], ],
"50": [
-752.0,
510.0
],
"53": [
-1203.0,
545.0
],
"54": [
-963.0,
628.0
],
"55": [
-1163.0,
821.0
],
"6": [ "6": [
-275.0, -275.0,
-428.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": [ "7": [
-1.0, -1.0,
-448.0 -448.0
], ],
"72": [
-560.782958984375,
1034.2183837890625
],
"74": [
-558.856201171875,
1217.7781982421875
],
"75": [
-259.782958984375,
1078.2183837890625
],
"76": [
-400.782958984375,
1089.2183837890625
],
"9": [ "9": [
158.0, 158.0,
-530.0 -530.0
], ],
"__worldOutput": [ "__worldOutput": [
400.0, 367.0,
0.0 540.0
] ]
}, },
"previewOriginX": 0, "previewOriginX": 0,
@@ -149,6 +201,8 @@
"worldOutputPasses": [ "worldOutputPasses": [
9, 9,
18, 18,
48,
62,
24 24
] ]
}, },
@@ -215,32 +269,7 @@
"to": 24 "to": 24
}, },
{ {
"from": 26, "from": 50,
"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,
"slot": 0, "slot": 0,
"to": 31 "to": 31
}, },
@@ -255,7 +284,7 @@
"to": 33 "to": 33
}, },
{ {
"from": 32, "from": 54,
"slot": 1, "slot": 1,
"to": 33 "to": 33
}, },
@@ -308,9 +337,84 @@
"from": 46, "from": 46,
"slot": 1, "slot": 1,
"to": 45 "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": [ "nodes": [
{ {
"frequency": 0.07880000025033951, "frequency": 0.07880000025033951,
@@ -389,32 +493,6 @@
"id": 24, "id": 24,
"type": "IntBranch" "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, "id": 31,
"type": "And" "type": "And"
@@ -473,6 +551,99 @@
"id": 46, "id": 46,
"type": "Constant", "type": "Constant",
"value": 0.0 "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"
} }
] ]
} }

View File

@@ -35,6 +35,24 @@
], ],
"id": 3, "id": 3,
"name": "Tree" "name": "Tree"
},
{
"color": [
0.5555469989776611,
0.15063799917697906,
0.8152173757553101
],
"id": 4,
"name": "Water"
},
{
"color": [
1.0,
0.782608687877655,
0.0
],
"id": 5,
"name": "Clay"
} }
] ]
} }

View File

@@ -320,7 +320,7 @@ public:
QueryDistanceNode(int32_t id, int32_t maxDist) QueryDistanceNode(int32_t id, int32_t maxDist)
: tileID(id), maxDistance(maxDist) {} : tileID(id), maxDistance(maxDist) {}
Type GetOutputType() const override { return Type::Int; } Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; } std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryDistance"; } std::string GetName() const override { return "QueryDistance"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override { Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
@@ -333,7 +333,57 @@ public:
best = d; best = d;
} }
} }
return Value::MakeInt(best); return Value::MakeFloat(1.f - static_cast<float>(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<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryLiquid"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) 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 { class CellularNoiseNode : public Node {
public: public:
float frequency { 0.01f }; float frequency { 0.01f };
explicit CellularNoiseNode(float freq = 0.01f) : frequency(freq) {} explicit CellularNoiseNode(float freq = 0.01f) : frequency(freq) {}
Type GetOutputType() const override { return Type::Float; } Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return {}; } std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "CellularNoise"; } std::string GetName() const override { return "CellularNoise"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override { Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
FastNoiseLite fn; // Map world position into lattice space.
fn.SetSeed(static_cast<int>(ctx.seed)); float sx = ctx.worldX * frequency;
fn.SetNoiseType(FastNoiseLite::NoiseType_Cellular); float sy = ctx.worldY * frequency;
fn.SetFrequency(frequency); int32_t cellX = static_cast<int32_t>(std::floor(sx));
return Value::MakeFloat(fn.GetNoise(static_cast<float>(ctx.worldX), int32_t cellY = static_cast<int32_t>(std::floor(sy));
static_cast<float>(ctx.worldY)));
// 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<uint32_t>(ctx.seed)
^ (static_cast<uint32_t>(nx) * 2654435761u)
^ (static_cast<uint32_t>(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<float>(nx) + jx;
float fpY = static_cast<float>(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<int32_t>(std::round(bestFX / frequency));
auto centerY = static_cast<int32_t>(std::round(bestFY / frequency));
return Value::MakeBool(ctx.worldX == centerX && ctx.worldY == centerY);
} }
}; };

View File

@@ -77,6 +77,11 @@ PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNod
bounds.Include(-d, -d); bounds.Include(-d, -d);
bounds.Include( d, d); bounds.Include( d, d);
} }
else if (const auto* ql = dynamic_cast<const LiquidNode*>(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; return bounds;
} }

View File

@@ -87,6 +87,11 @@ std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
j.value("tileId", 0), j.value("tileId", 0),
j.value("maxDistance", 4)); j.value("maxDistance", 4));
} }
if (type == "QueryLiquid") {
return std::make_unique<LiquidNode>(
j.value("maxWidth", 8),
j.value("maxDepth", 4));
}
return nullptr; // unrecognised type return nullptr; // unrecognised type
} }
@@ -130,6 +135,9 @@ nlohmann::json GraphSerializer::ToJson(const Graph& g)
} else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(node)) { } else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(node)) {
jNode["tileId"] = qd->tileID; jNode["tileId"] = qd->tileID;
jNode["maxDistance"] = qd->maxDistance; jNode["maxDistance"] = qd->maxDistance;
} else if (const auto* ql = dynamic_cast<const LiquidNode*>(node)) {
jNode["maxWidth"] = ql->maxWidth;
jNode["maxDepth"] = ql->maxDepth;
} else if (const auto* mn = dynamic_cast<const MapNode*>(node)) { } else if (const auto* mn = dynamic_cast<const MapNode*>(node)) {
jNode["min0"] = mn->min0; jNode["min0"] = mn->min0;
jNode["max0"] = mn->max0; jNode["max0"] = mn->max0;

View File

@@ -416,13 +416,6 @@ TEST_SUITE("WorldGraph::Nodes") {
CHECK(v1 == doctest::Approx(v2)); 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]") { TEST_CASE("SimplexNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 7; c.worldY = 13; c.seed = 1; EvalContext c; c.worldX = 7; c.worldY = 13; c.seed = 1;
SimplexNoiseNode n(0.1f); SimplexNoiseNode n(0.1f);
@@ -472,11 +465,4 @@ TEST_SUITE("WorldGraph::Nodes") {
// Different frequencies must produce different values at the same position // Different frequencies must produce different values at the same position
CHECK(lowFreq.Evaluate(c, {}).AsFloat() != doctest::Approx(highFreq.Evaluate(c, {}).AsFloat())); 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()));
}
} }

View File

@@ -0,0 +1,667 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraphNode.h"
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphSerializer.h"
#include <cmath>
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<int>(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<double>(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<int>(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<PositionXNode>());
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto cFreq = g.AddNode(std::make_unique<ConstantNode>(freq));
auto cAmp = g.AddNode(std::make_unique<ConstantNode>(amp));
auto cBias = g.AddNode(std::make_unique<ConstantNode>(bias));
auto mulF = g.AddNode(std::make_unique<MultiplyNode>());
auto sinN = g.AddNode(std::make_unique<SinNode>());
auto mulA = g.AddNode(std::make_unique<MultiplyNode>());
auto addB = g.AddNode(std::make_unique<AddNode>());
auto less = g.AddNode(std::make_unique<LessNode>());
auto branch = g.AddNode(std::make_unique<BranchNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(STONE));
auto air = g.AddNode(std::make_unique<IDNode>(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<LiquidNode>(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<LiquidNode>());
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<LiquidNode>(6, 3));
auto qt = g.AddNode(std::make_unique<QueryTileNode>(-10, 2, STONE));
auto br = g.AddNode(std::make_unique<BranchNode>());
auto id0 = g.AddNode(std::make_unique<IDNode>(0));
auto id1 = g.AddNode(std::make_unique<IDNode>(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<AndNode>());
REQUIRE(g.Connect(lq, andN, 0));
REQUIRE(g.Connect(qt, andN, 1));
auto br2 = g.AddNode(std::make_unique<BranchNode>());
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<LiquidNode>(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<const LiquidNode*>(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<LiquidNode>(2, 5));
auto json = GraphSerializer::ToJson(g);
auto nodes = json["nodes"];
REQUIRE(nodes.is_array());
REQUIRE(nodes.size() == 1);
CHECK(nodes[0]["type"].get<std::string>() == "QueryLiquid");
CHECK(nodes[0]["maxWidth"].get<int>() == 2);
CHECK(nodes[0]["maxDepth"].get<int>() == 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<LiquidNode>(MW, MD));
auto water = liquidG.AddNode(std::make_unique<IDNode>(WATER));
auto none = liquidG.AddNode(std::make_unique<IDNode>(AIR));
auto br = liquidG.AddNode(std::make_unique<BranchNode>());
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);
}
}
}
}

View File

@@ -248,6 +248,7 @@ static const std::vector<NodeMenuItem> NODE_MENU = {
{ "QueryTile", "Query", [] { return std::make_unique<QueryTileNode>(0, -1, 1); } }, { "QueryTile", "Query", [] { return std::make_unique<QueryTileNode>(0, -1, 1); } },
{ "QueryRange", "Query", [] { return std::make_unique<QueryRangeNode>(-1, -1, 1, 1, 1); } }, { "QueryRange", "Query", [] { return std::make_unique<QueryRangeNode>(-1, -1, 1, 1, 1); } },
{ "QueryDistance", "Query", [] { return std::make_unique<QueryDistanceNode>(1, 4); } }, { "QueryDistance", "Query", [] { return std::make_unique<QueryDistanceNode>(1, 4); } },
{ "QueryLiquid", "Query", [] { return std::make_unique<LiquidNode>(8, 4); } },
// ── Noise ─────────────────────────────────────────────────────────────── // ── Noise ───────────────────────────────────────────────────────────────
{ "Random", "Noise", [] { return std::make_unique<RandomNode>(); } }, { "Random", "Noise", [] { return std::make_unique<RandomNode>(); } },
{ "PerlinNoise", "Noise", [] { return std::make_unique<PerlinNoiseNode>(0.01f); } }, { "PerlinNoise", "Noise", [] { return std::make_unique<PerlinNoiseNode>(0.01f); } },
@@ -678,13 +679,19 @@ private:
ImNodes::SetNodeGridSpacePos(WORLD_OUTPUT_IMNODES_ID, ImVec2(400.0f, 0.0f)); 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(); ImNodes::BeginNodeEditor();
// Right-click blank canvas → add node menu // Right-click blank canvas → add node menu
if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) && if (ImGui::IsWindowHovered(ImGuiFocusedFlags_RootAndChildWindows) &&
ImNodes::IsEditorHovered() && ImNodes::IsEditorHovered() &&
ImGui::IsMouseReleased(ImGuiMouseButton_Right) && ImGui::IsMouseReleased(ImGuiMouseButton_Right) &&
!ImGui::IsAnyItemHovered()) !ImGui::IsAnyItemHovered() &&
!s_linkWasHovered)
{ {
ImGui::OpenPopup("##add_node_menu"); ImGui::OpenPopup("##add_node_menu");
} }
@@ -778,6 +785,11 @@ private:
ImNodes::EndNodeEditor(); 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 ──────────────────────────────────────────── // ── Handle new connections ────────────────────────────────────────────
int fromAttr, toAttr; int fromAttr, toAttr;
@@ -834,11 +846,9 @@ private:
// ── Handle link deletion ────────────────────────────────────────────── // ── Handle link deletion ──────────────────────────────────────────────
int destroyedLink; auto destroyLink = [&](int linkId) {
if (ImNodes::IsLinkDestroyed(&destroyedLink)) { if (linkId >= WORLD_OUTPUT_LINK_BASE) {
if (destroyedLink >= WORLD_OUTPUT_LINK_BASE) { int passIdx = linkId - WORLD_OUTPUT_LINK_BASE;
// World Output link
int passIdx = destroyedLink - WORLD_OUTPUT_LINK_BASE;
if (passIdx >= 0 && passIdx < static_cast<int>(tab.worldOutputPasses.size())) { if (passIdx >= 0 && passIdx < static_cast<int>(tab.worldOutputPasses.size())) {
tab.worldOutputPasses[passIdx] = Graph::INVALID_ID; tab.worldOutputPasses[passIdx] = Graph::INVALID_ID;
tab.worldOutputDirty = true; tab.worldOutputDirty = true;
@@ -853,7 +863,7 @@ private:
if (!node) continue; if (!node) continue;
for (int slot = 0; slot < static_cast<int>(node->GetInputCount()); ++slot) { for (int slot = 0; slot < static_cast<int>(node->GetInputCount()); ++slot) {
if (tab.graph.GetInput(id, slot).has_value()) { if (tab.graph.GetInput(id, slot).has_value()) {
if (idx == destroyedLink) { if (idx == linkId) {
tab.graph.Disconnect(id, slot); tab.graph.Disconnect(id, slot);
MarkAllDirty(); MarkAllDirty();
found = true; 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 ───────────────────────────────────────────── // ── Delete selected nodes ─────────────────────────────────────────────
@@ -1101,6 +1130,19 @@ private:
return changed; return changed;
} }
static bool DrawLiquidNodeParams(NodeEditorApp& /*app*/, Node* node)
{
auto* n = static_cast<LiquidNode*>(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) static bool DrawMapParams(NodeEditorApp& /*app*/, Node* node)
{ {
auto* n = static_cast<MapNode*>(node); auto* n = static_cast<MapNode*>(node);
@@ -1139,6 +1181,7 @@ private:
{ typeid(QueryTileNode), DrawQueryTileParams }, { typeid(QueryTileNode), DrawQueryTileParams },
{ typeid(QueryRangeNode), DrawQueryRangeParams }, { typeid(QueryRangeNode), DrawQueryRangeParams },
{ typeid(QueryDistanceNode), DrawQueryDistanceParams }, { typeid(QueryDistanceNode), DrawQueryDistanceParams },
{ typeid(LiquidNode), DrawLiquidNodeParams },
{ typeid(MapNode), DrawMapParams }, { typeid(MapNode), DrawMapParams },
{ typeid(PerlinNoiseNode), DrawPerlinNoiseParams }, { typeid(PerlinNoiseNode), DrawPerlinNoiseParams },
{ typeid(SimplexNoiseNode), DrawSimplexNoiseParams }, { typeid(SimplexNoiseNode), DrawSimplexNoiseParams },