layer strength

This commit is contained in:
Connor
2026-02-23 23:07:10 +09:00
parent 1e6a8d4f60
commit 1b7fd1c7f8
10 changed files with 725 additions and 230 deletions

2
.gitignore vendored
View File

@@ -531,7 +531,6 @@ qrc_*.cpp
ui_*.h
*.qmlc
*.jsc
Makefile*
*build-*
*.qm
*.prl
@@ -941,7 +940,6 @@ CMakeCache.txt
CMakeFiles
CMakeScripts
Testing
Makefile
cmake_install.cmake
install_manifest.txt
compile_commands.json

26
Makefile Normal file
View File

@@ -0,0 +1,26 @@
BUILD_DIR := build
BUILD_TYPE ?= Debug
.PHONY: all configure build run test clean world-editor
all: build
configure:
@cmake -S . -B $(BUILD_DIR) -DCMAKE_BUILD_TYPE=$(BUILD_TYPE)
build: configure
@cmake --build $(BUILD_DIR) --target factory-hole-app -j$$(nproc)
run: build
@$(BUILD_DIR)/factory-hole-app
test: configure
@cmake --build $(BUILD_DIR) --target factory-hole-tests -j$$(nproc)
@cd $(BUILD_DIR) && ctest --output-on-failure
world-editor: configure
@cmake --build $(BUILD_DIR) --target node-editor -j$$(nproc)
@$(BUILD_DIR)/tools/node-editor/node-editor
clean:
@rm -rf $(BUILD_DIR)

View File

@@ -1,49 +1,141 @@
{
"editor": {
"nodePositions": {
"1": [
-1011.0,
-214.0
],
"10": [
-601.0,
-250.0
],
"11": [
-337.0,
-265.0
],
"12": [
-351.2725830078125,
240.48638916015625
-504.2725830078125,
-131.51361083984375
],
"2": [
-740.0,
-231.0
"13": [
-195.0,
-26.0
],
"3": [
-885.0,
-153.0
"15": [
-184.0,
-228.0
],
"16": [
-329.0,
-47.0
],
"17": [
-20.0,
-149.0
],
"18": [
131.0,
-75.0
],
"19": [
-9.0,
76.0
],
"21": [
-380.255615234375,
459.421142578125
],
"23": [
-154.0,
248.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
],
"31": [
-7.0,
389.0
],
"32": [
-588.0,
597.0
],
"33": [
-151.0,
497.0
],
"34": [
-1580.6732177734375,
-252.9154052734375
],
"37": [
-938.6732788085938,
-13.9154052734375
],
"39": [
-1082.0,
58.0
],
"4": [
-472.0,
-118.0
-261.0,
-633.0
],
"40": [
-1297.0,
179.0
],
"41": [
-858.0,
-289.0
],
"42": [
-1519.0,
-63.0
],
"43": [
-1431.0,
-267.0
],
"44": [
-1261.0,
-226.0
],
"45": [
-666.0,
-158.0
],
"46": [
-799.0,
33.0
],
"5": [
-144.0,
-283.0
-122.0,
-513.0
],
"6": [
-341.0,
-61.0
-275.0,
-428.0
],
"7": [
-23.0,
-218.0
-1.0,
-448.0
],
"9": [
136.0,
-300.0
158.0,
-530.0
],
"__worldOutput": [
400.0,
@@ -52,65 +144,18 @@
},
"previewOriginX": 0,
"previewOriginY": 0,
"previewScale": 0.4000000059604645,
"previewScale": 0.5,
"seed": 0,
"tileRegistry": [
{
"color": [
0.11764705926179886,
0.11764705926179886,
0.11764705926179886
],
"id": 0,
"name": "Empty"
},
{
"color": [
0.6958129405975342,
0.6926098465919495,
0.7119565010070801
],
"id": 1,
"name": "Stone"
},
{
"color": [
0.05567699670791626,
0.7880434989929199,
0.09547946602106094
],
"id": 2,
"name": "Dirt"
},
{
"color": [
0.2771739363670349,
0.06326796859502792,
0.0
],
"id": 3,
"name": "Tree"
}
],
"worldOutputPasses": [
9,
0
18,
24
]
},
"graph": {
"connections": [
{
"from": 1,
"slot": 0,
"to": 2
},
{
"from": 3,
"slot": 1,
"to": 2
},
{
"from": 11,
"from": 4,
"slot": 0,
"to": 5
},
@@ -130,36 +175,143 @@
"to": 9
},
{
"from": 2,
"from": 12,
"slot": 0,
"to": 10
"to": 15
},
{
"from": 10,
"slot": 0,
"to": 11
},
{
"from": 4,
"from": 16,
"slot": 1,
"to": 11
"to": 15
},
{
"from": 15,
"slot": 0,
"to": 17
},
{
"from": 13,
"slot": 1,
"to": 17
},
{
"from": 17,
"slot": 0,
"to": 18
},
{
"from": 19,
"slot": 1,
"to": 18
},
{
"from": 31,
"slot": 0,
"to": 24
},
{
"from": 23,
"slot": 1,
"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,
"slot": 0,
"to": 31
},
{
"from": 33,
"slot": 1,
"to": 31
},
{
"from": 21,
"slot": 0,
"to": 33
},
{
"from": 32,
"slot": 1,
"to": 33
},
{
"from": 39,
"slot": 0,
"to": 37
},
{
"from": 44,
"slot": 0,
"to": 39
},
{
"from": 40,
"slot": 1,
"to": 39
},
{
"from": 43,
"slot": 0,
"to": 41
},
{
"from": 42,
"slot": 1,
"to": 41
},
{
"from": 34,
"slot": 0,
"to": 43
},
{
"from": 43,
"slot": 0,
"to": 44
},
{
"from": 42,
"slot": 1,
"to": 44
},
{
"from": 37,
"slot": 0,
"to": 45
},
{
"from": 46,
"slot": 1,
"to": 45
}
],
"nextId": 13,
"nextId": 47,
"nodes": [
{
"id": 1,
"type": "PositionY"
},
{
"id": 2,
"type": "Multiply"
},
{
"id": 3,
"type": "Constant",
"value": 0.019999999552965164
},
{
"frequency": 0.07880000025033951,
"id": 4,
@@ -172,7 +324,7 @@
{
"id": 6,
"type": "Constant",
"value": 0.1599999964237213
"value": 0.17000000178813934
},
{
"id": 7,
@@ -184,21 +336,143 @@
"type": "IntBranch"
},
{
"id": 10,
"type": "Max"
"id": 12,
"maxX": 0,
"maxY": 2,
"minX": 0,
"minY": 0,
"tileId": 0,
"type": "QueryRange"
},
{
"id": 11,
"expectedID": 1,
"id": 13,
"offsetX": 0,
"offsetY": 0,
"type": "QueryTile"
},
{
"id": 15,
"type": "GreaterEqual"
},
{
"id": 16,
"type": "Constant",
"value": 1.0
},
{
"id": 17,
"type": "And"
},
{
"id": 18,
"type": "IntBranch"
},
{
"id": 19,
"tileId": 2,
"type": "TileID"
},
{
"expectedID": 2,
"id": 21,
"offsetX": 0,
"offsetY": -1,
"type": "QueryTile"
},
{
"id": 23,
"tileId": 3,
"type": "TileID"
},
{
"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"
},
{
"expectedID": 0,
"id": 32,
"offsetX": 0,
"offsetY": 0,
"type": "QueryTile"
},
{
"id": 33,
"type": "And"
},
{
"frequency": 0.09839999675750732,
"id": 34,
"type": "CellularNoise"
},
{
"id": 37,
"type": "Ceil"
},
{
"id": 39,
"type": "Add"
},
{
"id": 40,
"type": "Constant",
"value": -0.5
},
{
"id": 41,
"type": "Multiply"
},
{
"id": 12,
"maxX": 1,
"maxY": 1,
"minX": -1,
"minY": -1,
"tileId": 1,
"type": "QueryRange"
"id": 42,
"type": "Constant",
"value": 500.0
},
{
"id": 43,
"type": "Negate"
},
{
"id": 44,
"type": "Pow"
},
{
"id": 45,
"type": "Greater"
},
{
"id": 46,
"type": "Constant",
"value": 0.0
}
]
}

View File

@@ -1,10 +1,6 @@
{
"editor": {
"nodePositions": {
"1": [
-70.0,
-259.0
],
"13": [
-220.0,
24.0
@@ -17,6 +13,10 @@
-223.0,
206.0
],
"17": [
-190.0,
-250.0
],
"2": [
112.0,
-289.0
@@ -34,26 +34,6 @@
"previewOriginY": 0,
"previewScale": 0.4000000059604645,
"seed": 0,
"tileRegistry": [
{
"color": [
0.11764705926179886,
0.11764705926179886,
0.11764705926179886
],
"id": 0,
"name": "Empty"
},
{
"color": [
0.7348794341087341,
0.730800986289978,
0.7554347515106201
],
"id": 1,
"name": "Stone"
}
],
"worldOutputPasses": [
2
]
@@ -66,8 +46,8 @@
"to": 2
},
{
"from": 1,
"slot": 1,
"from": 17,
"slot": 2,
"to": 2
},
{
@@ -86,13 +66,8 @@
"to": 15
}
],
"nextId": 17,
"nextId": 18,
"nodes": [
{
"id": 1,
"tileId": 1,
"type": "TileID"
},
{
"id": 2,
"type": "IntBranch"
@@ -114,6 +89,11 @@
"id": 16,
"type": "Constant",
"value": 0.3199999928474426
},
{
"id": 17,
"tileId": 1,
"type": "TileID"
}
]
}

40
data/tiles.json Normal file
View File

@@ -0,0 +1,40 @@
{
"tiles": [
{
"color": [
1.0,
1.0,
1.0
],
"id": 0,
"name": "Empty"
},
{
"color": [
0.532608687877655,
0.532608687877655,
0.532608687877655
],
"id": 1,
"name": "Stone"
},
{
"color": [
0.2549019753932953,
0.8470588326454163,
0.43529412150382996
],
"id": 2,
"name": "Grass"
},
{
"color": [
0.0,
0.32065218687057495,
0.01742672547698021
],
"id": 3,
"name": "Tree"
}
]
}

View File

@@ -1,10 +1,10 @@
[Window][Node Canvas]
Pos=200,42
Size=1720,1039
Size=1080,678
[Window][Settings]
Pos=0,42
Size=200,1039
Size=200,678
[Window][Debug##Default]
Pos=60,60
@@ -18,6 +18,10 @@ Size=600,400
Pos=60,60
Size=600,400
[Window][Save Tile Registry##SaveRegistryDlg]
Pos=60,60
Size=600,400
[Table][0x4ED31530,4]
RefScale=13
Column 0 Sort=0v
@@ -86,7 +90,12 @@ Column 0 Sort=0v
RefScale=13
Column 0 Sort=0v
[Table][0x493FDC9E,4]
RefScale=13
Column 0 Sort=0v
[NodeEditor][Session]
LastFiles=/home/connor/repos/factory-hole-core/data/WorldGraphs/stone.json;/home/connor/repos/factory-hole-core/data/WorldGraphs/dirt.json
ActiveTab=1
SharedRegistry=/home/connor/repos/factory-hole-core/data/tiles.json

View File

@@ -295,7 +295,7 @@ public:
QueryRangeNode(int32_t mnX, int32_t mnY, int32_t mxX, int32_t mxY, int32_t id)
: minX(mnX), minY(mnY), maxX(mxX), maxY(mxY), tileID(id) {}
Type GetOutputType() const override { return Type::Int; }
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryRange"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
@@ -304,7 +304,7 @@ public:
for (int32_t dx = minX; dx <= maxX; ++dx)
if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID)
++count;
return Value::MakeInt(count);
return Value::MakeFloat(static_cast<float>(count));
}
};
@@ -381,6 +381,16 @@ public:
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override { return Value::MakeFloat(ctx.worldY); };
};
/// Outputs the current layer's blending strength, set by the world compositor.
/// Returns 1.0 when running outside a layered world (normal graph evaluation).
class LayerStrengthNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "LayerStrength"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override { return Value::MakeFloat(ctx.layerStrength); }
};
// ─────────────────────────────── Abs / Negate ────────────────────────────────
/// |a| (Float)
@@ -501,6 +511,30 @@ public:
// ─────────────────────────────── Extended math nodes ─────────────────────────
/// floor(a) (Float)
class FloorNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Floor"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::floor(in[0].AsFloat()));
}
};
/// ceil(a) (Float)
class CeilNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Ceil"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::ceil(in[0].AsFloat()));
}
};
/// sqrt(a), clamped to 0 for negative inputs (Float)
class SqrtNode : public Node {
public:
@@ -605,6 +639,28 @@ public:
// Each noise node reads worldX/Y and seed from EvalContext; no graph inputs.
// Output is a Float in [-1, 1].
//
// RandomNode is the exception: it produces spatially incoherent white noise
// via a hash of (seed, worldX, worldY), output in [0, 1].
/// Spatially incoherent hash-based random value in [0, 1].
/// Each (seed, worldX, worldY) triple produces an independent value — no
/// smoothing, no spatial correlation. (Float)
class RandomNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "Random"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
// Mix seed, x, y with Knuth multiplicative hashes then finalise.
uint32_t h = static_cast<uint32_t>(ctx.seed)
^ (static_cast<uint32_t>(ctx.worldX) * 2654435761u)
^ (static_cast<uint32_t>(ctx.worldY) * 2246822519u);
h ^= h >> 16;
h *= 0x45d9f3bu;
h ^= h >> 16;
return Value::MakeFloat((h & 0xFFFFFFu) / static_cast<float>(0x1000000u));
}
};
// A new FastNoiseLite object is constructed per evaluation so that the seed
// from the context is applied correctly across every cell.

View File

@@ -44,6 +44,11 @@ struct EvalContext {
int32_t prevWidth { 0 };
int32_t prevHeight { 0 };
// ── Layer blending strength ────────────────────────────────────────────
// Set by the world layer compositor before calling Evaluate().
// 1.0 = this layer is fully active; 0.0 = this layer has no influence.
float layerStrength { 1.0f };
/// Query the previous pass at an absolute world position.
/// Returns 0 (AIR / empty) when no previous pass or position is out of bounds.
inline int32_t GetPrevTile(int32_t x, int32_t y) const noexcept;

View File

@@ -31,9 +31,12 @@ std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
if (type == "IntBranch") return std::make_unique<IntBranchNode>();
if (type == "PositionX") return std::make_unique<PositionXNode>();
if (type == "PositionY") return std::make_unique<PositionYNode>();
if (type == "LayerStrength") return std::make_unique<LayerStrengthNode>();
if (type == "Sin") return std::make_unique<SinNode>();
if (type == "Cos") return std::make_unique<CosNode>();
if (type == "Modulo") return std::make_unique<ModuloNode>();
if (type == "Floor") return std::make_unique<FloorNode>();
if (type == "Ceil") return std::make_unique<CeilNode>();
if (type == "Sqrt") return std::make_unique<SqrtNode>();
if (type == "Pow") return std::make_unique<PowNode>();
if (type == "Square") return std::make_unique<SquareNode>();
@@ -51,7 +54,8 @@ std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
j.value("max1", 1.0f));
}
// Noise nodes (frequency baked in)
// Noise nodes
if (type == "Random") return std::make_unique<RandomNode>();
if (type == "PerlinNoise") return std::make_unique<PerlinNoiseNode>(j.value("frequency", 0.01f));
if (type == "SimplexNoise") return std::make_unique<SimplexNoiseNode>(j.value("frequency", 0.01f));
if (type == "CellularNoise") return std::make_unique<CellularNoiseNode>(j.value("frequency",0.01f));

View File

@@ -162,6 +162,7 @@ static ImU32 PinColorHovered(Type t)
static constexpr int PREVIEW_SIZE = 64;
static constexpr float PREVIEW_SCALE = 1.5f; // world units per pixel
static constexpr float FINAL_PREVIEW_SCALE = 4.0f; // for World Output node
static GLuint MakePreviewTexture()
{
@@ -205,6 +206,7 @@ static const std::vector<NodeMenuItem> NODE_MENU = {
{ "TileID", "Source", [] { return std::make_unique<IDNode>(1); } },
{ "PositionX", "Source", [] { return std::make_unique<PositionXNode>(); } },
{ "PositionY", "Source", [] { return std::make_unique<PositionYNode>(); } },
{ "LayerStrength", "Source", [] { return std::make_unique<LayerStrengthNode>(); } },
// ── Math ────────────────────────────────────────────────────────────────
{ "Add", "Math", [] { return std::make_unique<AddNode>(); } },
{ "Subtract", "Math", [] { return std::make_unique<SubtractNode>(); } },
@@ -213,6 +215,8 @@ static const std::vector<NodeMenuItem> NODE_MENU = {
{ "Modulo", "Math", [] { return std::make_unique<ModuloNode>(); } },
{ "Sin", "Math", [] { return std::make_unique<SinNode>(); } },
{ "Cos", "Math", [] { return std::make_unique<CosNode>(); } },
{ "Floor", "Math", [] { return std::make_unique<FloorNode>(); } },
{ "Ceil", "Math", [] { return std::make_unique<CeilNode>(); } },
{ "Sqrt", "Math", [] { return std::make_unique<SqrtNode>(); } },
{ "Pow", "Math", [] { return std::make_unique<PowNode>(); } },
{ "Square", "Math", [] { return std::make_unique<SquareNode>(); } },
@@ -245,6 +249,7 @@ static const std::vector<NodeMenuItem> NODE_MENU = {
{ "QueryRange", "Query", [] { return std::make_unique<QueryRangeNode>(-1, -1, 1, 1, 1); } },
{ "QueryDistance", "Query", [] { return std::make_unique<QueryDistanceNode>(1, 4); } },
// ── Noise ───────────────────────────────────────────────────────────────
{ "Random", "Noise", [] { return std::make_unique<RandomNode>(); } },
{ "PerlinNoise", "Noise", [] { return std::make_unique<PerlinNoiseNode>(0.01f); } },
{ "SimplexNoise", "Noise", [] { return std::make_unique<SimplexNoiseNode>(0.01f); } },
{ "CellularNoise", "Noise", [] { return std::make_unique<CellularNoiseNode>(0.01f); } },
@@ -260,7 +265,6 @@ struct GraphTab {
Graph graph;
std::unordered_map<Graph::NodeID, VisualNode> visualNodes;
std::vector<TileEntry> tileRegistry;
std::vector<Graph::NodeID> worldOutputPasses;
GLuint worldOutputTex { 0 };
@@ -276,7 +280,6 @@ struct GraphTab {
void Init()
{
tileRegistry = { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} };
worldOutputPasses = { Graph::INVALID_ID };
worldOutputTex = MakePreviewTexture();
imnodesCtx = ImNodes::EditorContextCreate();
@@ -317,6 +320,10 @@ public:
void Render()
{
// Restore last session written by the ini settings handler.
if (!pendingLoadRegistry.empty()) {
LoadSharedRegistry(pendingLoadRegistry);
pendingLoadRegistry.clear();
}
if (!pendingLoadFiles.empty()) {
for (const auto& f : pendingLoadFiles) {
auto& cur = Active();
@@ -378,9 +385,16 @@ private:
int activeTab { 0 };
int requestedTab { -1 }; // set to trigger programmatic tab switch (one frame)
// Shared tile registry — one across all tabs / graph files
std::vector<TileEntry> sharedRegistry {
TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}}
};
std::string sharedRegistryPath;
// Pending data from ini settings handler
std::vector<std::string> pendingLoadFiles;
int pendingActiveTab { -1 };
std::string pendingLoadRegistry;
GraphTab& Active() { return tabs[activeTab]; }
@@ -449,12 +463,12 @@ private:
for (int px = 0; px < PREVIEW_SIZE; ++px) {
EvalContext ctx;
ctx.worldX = tab.previewOriginX + static_cast<int>(px * tab.previewScale);
ctx.worldY = tab.previewOriginY + static_cast<int>(py * tab.previewScale);
ctx.worldY = tab.previewOriginY + static_cast<int>((PREVIEW_SIZE - 1 - py) * tab.previewScale);
ctx.seed = tab.previewSeed;
Value v = tab.graph.Evaluate(id, ctx);
int base = (py * PREVIEW_SIZE + px) * 4;
ValueToRGBA(v, &tab.tileRegistry,
ValueToRGBA(v, &sharedRegistry,
pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]);
}
}
@@ -485,10 +499,10 @@ private:
for (int py = 0; py < PREVIEW_SIZE; ++py) {
for (int px = 0; px < PREVIEW_SIZE; ++px) {
int32_t tileID = chunk.Get(tab.previewOriginX + px, tab.previewOriginY + py);
int32_t tileID = chunk.Get(tab.previewOriginX + px, tab.previewOriginY + (PREVIEW_SIZE - 1 - py));
int base = (py * PREVIEW_SIZE + px) * 4;
Value v = Value::MakeInt(tileID);
ValueToRGBA(v, &tab.tileRegistry,
ValueToRGBA(v, &sharedRegistry,
pixels[base], pixels[base+1], pixels[base+2], pixels[base+3]);
}
}
@@ -546,10 +560,27 @@ private:
ImGui::Spacing();
ImGui::SeparatorText("Tile IDs");
// Registry file path (read-only display)
{
const char* rpath = sharedRegistryPath.empty() ? "(unsaved)" : sharedRegistryPath.c_str();
ImGui::TextDisabled("%s", rpath);
}
if (ImGui::SmallButton("Open##reg")) OpenRegistryDialog();
ImGui::SameLine();
if (ImGui::SmallButton("Save##reg"))
{
if (sharedRegistryPath.empty()) SaveRegistryDialog();
else SaveSharedRegistry(sharedRegistryPath);
}
ImGui::SameLine();
if (ImGui::SmallButton("Save As##reg")) SaveRegistryDialog();
ImGui::Spacing();
int toRemove = -1;
for (int i = 0; i < static_cast<int>(tab.tileRegistry.size()); ++i) {
for (int i = 0; i < static_cast<int>(sharedRegistry.size()); ++i) {
ImGui::PushID(i);
TileEntry& entry = tab.tileRegistry[i];
TileEntry& entry = sharedRegistry[i];
// Color button — opens a popup picker.
char cpopup[32];
@@ -593,11 +624,11 @@ private:
ImGui::PopID();
}
if (toRemove >= 0)
tab.tileRegistry.erase(tab.tileRegistry.begin() + toRemove);
sharedRegistry.erase(sharedRegistry.begin() + toRemove);
if (ImGui::SmallButton("+ Add Tile")) {
int32_t nextId = tab.tileRegistry.empty() ? 1
: tab.tileRegistry.back().id + 1;
int32_t nextId = sharedRegistry.empty() ? 1
: sharedRegistry.back().id + 1;
TileEntry entry;
entry.id = nextId;
entry.name = "Tile";
@@ -608,7 +639,7 @@ private:
entry.color[0] = hr / 255.0f;
entry.color[1] = hg / 255.0f;
entry.color[2] = hb / 255.0f;
tab.tileRegistry.push_back(entry);
sharedRegistry.push_back(entry);
}
ImGui::Spacing();
@@ -913,7 +944,7 @@ private:
// Generated chunk preview — always 1 tile per pixel.
ImGui::Image(
static_cast<ImTextureID>(static_cast<uint64_t>(tab.worldOutputTex)),
ImVec2(PREVIEW_SIZE * PREVIEW_SCALE, PREVIEW_SIZE * PREVIEW_SCALE));
ImVec2(PREVIEW_SIZE * FINAL_PREVIEW_SCALE, PREVIEW_SIZE * FINAL_PREVIEW_SCALE));
ImNodes::EndNode();
@@ -986,38 +1017,46 @@ private:
return ImGui::DragFloat("##val", &n->value, 0.01f);
}
static bool DrawIDParams(NodeEditorApp& app, Node* node)
static bool DrawIDDropdown(const char* label, NodeEditorApp& app, int& currentID)
{
auto* n = static_cast<IDNode*>(node);
auto& reg = app.Active().tileRegistry;
bool changed = false;
if (reg.empty()) {
changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999);
} else {
auto& reg = app.sharedRegistry;
int selIdx = -1;
bool changed = false;
for (int i = 0; i < static_cast<int>(reg.size()); ++i)
if (reg[i].id == n->tileID) { selIdx = i; break; }
if (reg[i].id == currentID) { selIdx = i; break; }
const char* preview = selIdx >= 0 ? reg[selIdx].name.c_str() : "???";
ImGui::SetNextItemWidth(80);
if (ImGui::BeginCombo("##tile", preview)) {
if (ImGui::BeginCombo(label, preview)) {
for (int i = 0; i < static_cast<int>(reg.size()); ++i) {
bool sel = (i == selIdx);
char label[80];
snprintf(label, sizeof(label), "%s (%d)",
reg[i].name.c_str(), reg[i].id);
if (ImGui::Selectable(label, sel)) {
n->tileID = reg[i].id;
currentID = reg[i].id;
changed = true;
}
if (sel) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
return changed;
}
static bool DrawIDParams(NodeEditorApp& app, Node* node)
{
auto* n = static_cast<IDNode*>(node);
auto& reg = app.sharedRegistry;
bool changed = false;
if (reg.empty()) {
changed = ImGui::DragInt("##id", &n->tileID, 1, 0, 9999);
} else {
changed |= DrawIDDropdown("##id", app, n->tileID);
}
return changed;
}
static bool DrawQueryTileParams(NodeEditorApp& /*app*/, Node* node)
static bool DrawQueryTileParams(NodeEditorApp& app, Node* node)
{
auto* n = static_cast<QueryTileNode*>(node);
bool changed = false;
@@ -1028,33 +1067,35 @@ private:
changed |= ImGui::DragInt("##oy", &n->offsetY, 1);
// TODO: it would be best if this is a dropdown of tile ids that the user can choose from
ImGui::Text("Tile ID");
changed |= ImGui::DragInt("##eid", &n->expectedID, 1, 0, 1024);
changed |= DrawIDDropdown("##eid", app, n->expectedID);
ImGui::PopItemWidth();
return changed;
}
static bool DrawQueryRangeParams(NodeEditorApp& /*app*/, Node* node)
static bool DrawQueryRangeParams(NodeEditorApp& app, Node* node)
{
auto* n = static_cast<QueryRangeNode*>(node);
bool changed = false;
ImGui::PushItemWidth(70);
ImGui::Text("min x -> max x");
changed |= ImGui::DragInt("##mnx", &n->minX, 1);
ImGui::SameLine();
changed |= ImGui::DragInt("##mxx", &n->maxX, 1);
ImGui::Text("min y -> max y");
changed |= ImGui::DragInt("##mny", &n->minY, 1);
ImGui::SameLine();
changed |= ImGui::DragInt("##mxy", &n->maxY, 1);
changed |= ImGui::DragInt("##rtid", &n->tileID, 1, 0, 1024);
changed |= DrawIDDropdown("##rtid", app, n->tileID);
ImGui::PopItemWidth();
return changed;
}
static bool DrawQueryDistanceParams(NodeEditorApp& /*app*/, Node* node)
static bool DrawQueryDistanceParams(NodeEditorApp& app, Node* node)
{
auto* n = static_cast<QueryDistanceNode*>(node);
bool changed = false;
ImGui::PushItemWidth(70);
changed |= ImGui::DragInt("##dtid", &n->tileID, 1, 0, 1024);
changed |= DrawIDDropdown("##dtid", app, n->tileID);
changed |= ImGui::DragInt("##md", &n->maxDistance, 1, 1, 32);
ImGui::PopItemWidth();
return changed;
@@ -1126,6 +1167,75 @@ private:
// ── Serialization ─────────────────────────────────────────────────────────
// ── Shared tile registry I/O ──────────────────────────────────────────────
void SaveSharedRegistry(const std::string& path)
{
nlohmann::json j;
auto& tiles = j["tiles"] = nlohmann::json::array();
for (auto& t : sharedRegistry)
tiles.push_back({ {"id", t.id}, {"name", t.name},
{"color", { t.color[0], t.color[1], t.color[2] }} });
std::ofstream f(path);
if (f) { f << j.dump(2); sharedRegistryPath = path; }
else fprintf(stderr, "Failed to write registry %s\n", path.c_str());
}
void LoadSharedRegistry(const std::string& path)
{
std::ifstream f(path);
if (!f) { fprintf(stderr, "Cannot open registry %s\n", path.c_str()); return; }
nlohmann::json j;
try { j = nlohmann::json::parse(f); }
catch (...) { fprintf(stderr, "JSON parse error in registry %s\n", path.c_str()); return; }
if (!j.contains("tiles") || !j["tiles"].is_array()) return;
sharedRegistry.clear();
for (auto& t : j["tiles"]) {
TileEntry entry;
entry.id = t.value("id", 0);
entry.name = t.value("name", std::string("Tile"));
if (t.contains("color") && t["color"].size() == 3) {
entry.color[0] = t["color"][0].get<float>();
entry.color[1] = t["color"][1].get<float>();
entry.color[2] = t["color"][2].get<float>();
}
sharedRegistry.push_back(entry);
}
// Always ensure ID 0 sentinel is present.
bool hasEmpty = std::any_of(sharedRegistry.begin(), sharedRegistry.end(),
[](const TileEntry& e) { return e.id == 0; });
if (!hasEmpty)
sharedRegistry.insert(sharedRegistry.begin(),
TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}});
sharedRegistryPath = path;
MarkAllDirty();
}
void OpenRegistryDialog()
{
IGFD::FileDialogConfig cfg;
cfg.path = sharedRegistryPath.empty() ? "."
: std::filesystem::path(sharedRegistryPath).parent_path().string();
ImGuiFileDialog::Instance()->OpenDialog("OpenRegistryDlg", "Open Tile Registry", ".json", cfg);
}
void SaveRegistryDialog()
{
IGFD::FileDialogConfig cfg;
if (!sharedRegistryPath.empty()) {
std::filesystem::path p(sharedRegistryPath);
cfg.path = p.parent_path().string();
cfg.fileName = p.filename().string();
} else {
cfg.path = ".";
cfg.fileName = "tiles.json";
}
ImGuiFileDialog::Instance()->OpenDialog("SaveRegistryDlg", "Save Tile Registry", ".json", cfg);
}
void Save(const std::string& path)
{
auto& tab = Active();
@@ -1156,12 +1266,6 @@ private:
passes.push_back(id);
root["editor"]["worldOutputPasses"] = passes;
nlohmann::json tiles = nlohmann::json::array();
for (auto& t : tab.tileRegistry)
tiles.push_back({ {"id", t.id}, {"name", t.name},
{"color", { t.color[0], t.color[1], t.color[2] }} });
root["editor"]["tileRegistry"] = tiles;
std::ofstream f(path);
if (f) { f << root.dump(2); tab.currentFile = path; }
else fprintf(stderr, "Failed to write %s\n", path.c_str());
@@ -1188,7 +1292,6 @@ private:
tab.worldOutputDirty = true;
tab.pendingWorldOutputPos = true;
tab.currentFile = "";
tab.tileRegistry = { TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}} };
tab.graph = std::move(*newGraph);
@@ -1210,26 +1313,8 @@ private:
tab.previewOriginY = ed.value("previewOriginY", 0);
tab.previewScale = ed.value("previewScale", 1.0f);
if (ed.contains("tileRegistry")) {
tab.tileRegistry.clear();
for (auto& t : ed["tileRegistry"]) {
TileEntry entry;
entry.id = t.value("id", 0);
entry.name = t.value("name", std::string("Tile"));
if (t.contains("color") && t["color"].size() == 3) {
entry.color[0] = t["color"][0].get<float>();
entry.color[1] = t["color"][1].get<float>();
entry.color[2] = t["color"][2].get<float>();
}
tab.tileRegistry.push_back(entry);
}
// Always guarantee the Empty (ID 0) sentinel is present.
bool hasEmpty = std::any_of(tab.tileRegistry.begin(), tab.tileRegistry.end(),
[](const TileEntry& e) { return e.id == 0; });
if (!hasEmpty)
tab.tileRegistry.insert(tab.tileRegistry.begin(),
TileEntry{0, "Empty", {30.0f/255.0f, 30.0f/255.0f, 30.0f/255.0f}});
}
// Note: "tileRegistry" embedded in old .wge files is intentionally
// ignored — use the shared tiles.json registry instead.
if (ed.contains("worldOutputPasses")) {
tab.worldOutputPasses.clear();
@@ -1320,6 +1405,20 @@ public:
Save(ImGuiFileDialog::Instance()->GetFilePathName());
ImGuiFileDialog::Instance()->Close();
}
if (ImGuiFileDialog::Instance()->Display("OpenRegistryDlg",
ImGuiWindowFlags_NoCollapse, dlgSize)) {
if (ImGuiFileDialog::Instance()->IsOk())
LoadSharedRegistry(ImGuiFileDialog::Instance()->GetFilePathName());
ImGuiFileDialog::Instance()->Close();
}
if (ImGuiFileDialog::Instance()->Display("SaveRegistryDlg",
ImGuiWindowFlags_NoCollapse, dlgSize)) {
if (ImGuiFileDialog::Instance()->IsOk())
SaveSharedRegistry(ImGuiFileDialog::Instance()->GetFilePathName());
ImGuiFileDialog::Instance()->Close();
}
}
void HandleKeyboardShortcuts()
@@ -1358,6 +1457,8 @@ public:
}
if (strncmp(line, "ActiveTab=", 10) == 0)
app->pendingActiveTab = std::atoi(line + 10);
if (strncmp(line, "SharedRegistry=", 15) == 0)
app->pendingLoadRegistry = line + 15;
};
// Called when ImGui writes imgui.ini (periodically and on shutdown).
@@ -1370,12 +1471,14 @@ public:
files += tab.currentFile;
}
}
if (!files.empty()) {
out_buf->appendf("[NodeEditor][Session]\n");
if (!files.empty()) {
out_buf->appendf("LastFiles=%s\n", files.c_str());
out_buf->appendf("ActiveTab=%d\n", app->activeTab);
out_buf->appendf("\n");
}
if (!app->sharedRegistryPath.empty())
out_buf->appendf("SharedRegistry=%s\n", app->sharedRegistryPath.c_str());
out_buf->appendf("\n");
};
ImGui::AddSettingsHandler(&h);