Compare commits

...

7 Commits

Author SHA1 Message Date
Connor
bee5aa0e8f liquid 2026-02-24 17:22:41 +09:00
Connor
1b7fd1c7f8 layer strength 2026-02-23 23:07:10 +09:00
Connor
1e6a8d4f60 multiple files can be open at the same time 2026-02-22 20:39:58 +09:00
Connor
376442e95a more nodes 2026-02-22 15:40:25 +09:00
Connor
3d4453b9ea imgui implementation 2026-02-22 15:19:54 +09:00
Connor
7b80eda561 chunk generation 2026-02-22 11:57:39 +09:00
Connor
9b0c9a87fa basic graph and nodes + serialization 2026-02-22 11:03:41 +09:00
29 changed files with 7284 additions and 2140 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

View File

@@ -1,12 +1,14 @@
cmake_minimum_required(VERSION 3.20)
project(factory-hole-core LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
include(FetchContent)
# ---- Core dependencies -------------------------------------------------------
# Flecs ECS
FetchContent_Declare(
flecs
@@ -23,31 +25,111 @@ FetchContent_Declare(
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(flecs doctest)
# GLM vector/matrix math
FetchContent_Declare(
glm
GIT_REPOSITORY https://github.com/g-truc/glm.git
GIT_TAG 1.0.1
GIT_SHALLOW TRUE
)
# nlohmann/json graph serialisation
FetchContent_Declare(
nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
# ---- World-generation dependencies ------------------------------------------
# FastNoise2 noise nodes
FetchContent_Declare(
FastNoise2
GIT_REPOSITORY https://github.com/Auburn/FastNoise2.git
GIT_TAG master
GIT_SHALLOW TRUE
)
# FastNoiseLite single-header noise generation (used by WorldGraph noise nodes)
FetchContent_Declare(
FastNoiseLite
GIT_REPOSITORY https://github.com/Auburn/FastNoiseLite.git
GIT_TAG master
GIT_SHALLOW TRUE
)
# ---- Frontend dependencies (used by tools/) ----------------------------------
# GLFW windowing for the node-editor tool
FetchContent_Declare(
glfw
GIT_REPOSITORY https://github.com/glfw/glfw.git
GIT_TAG 3.4
GIT_SHALLOW TRUE
)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
# Dear ImGui
FetchContent_Declare(
imgui
GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.91.6
GIT_SHALLOW TRUE
)
# ImNodes node-graph editor widget for ImGui
FetchContent_Declare(
imnodes
GIT_REPOSITORY https://github.com/Nelarius/imnodes.git
GIT_TAG v0.5
GIT_SHALLOW TRUE
)
# ImGuiFileDialog file browser widget for the node-editor tool
FetchContent_Declare(
ImGuiFileDialog
GIT_REPOSITORY https://github.com/aiekick/ImGuiFileDialog.git
GIT_TAG v0.6.7
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(flecs doctest glm nlohmann_json glfw imgui imnodes FastNoiseLite)
# ---- Core library ------------------------------------------------------------
# Only compile sources needed for the core library
set(SOURCES
src/Components/Config/WorldConfig.cpp
src/Components/Support.cpp
src/Core/WorldInstance.cpp
src/WorldGraph/WorldGraph.cpp
src/WorldGraph/WorldGraphSerializer.cpp
src/WorldGraph/WorldGraphChunk.cpp
)
add_library(factory-hole-core ${SOURCES})
# Main executable
add_executable(factory-hole-app src/main.cpp)
target_link_libraries(factory-hole-app PRIVATE factory-hole-core)
target_include_directories(factory-hole-core PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
${fastnoiselite_SOURCE_DIR}/Cpp
)
target_link_libraries(factory-hole-core PUBLIC
flecs::flecs_static
doctest::doctest
glm::glm
nlohmann_json::nlohmann_json
)
# Tests
# ---- Main executable ---------------------------------------------------------
add_executable(factory-hole-app src/main.cpp)
target_link_libraries(factory-hole-app PRIVATE factory-hole-core)
# ---- Tests -------------------------------------------------------------------
enable_testing()
file(GLOB_RECURSE TEST_SOURCES tests/*.cpp)
@@ -56,6 +138,7 @@ add_executable(factory-hole-tests ${TEST_SOURCES})
target_include_directories(factory-hole-tests PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
${fastnoiselite_SOURCE_DIR}/Cpp
)
target_link_libraries(factory-hole-tests PRIVATE
@@ -64,3 +147,7 @@ target_link_libraries(factory-hole-tests PRIVATE
)
add_test(NAME factory-hole-tests COMMAND factory-hole-tests)
# ---- Tools -------------------------------------------------------------------
add_subdirectory(tools/node-editor)

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)

650
data/WorldGraphs/dirt.json Normal file
View File

@@ -0,0 +1,650 @@
{
"editor": {
"nodePositions": {
"12": [
-504.2725830078125,
-131.51361083984375
],
"13": [
-195.0,
-26.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": [
-786.255615234375,
714.421142578125
],
"23": [
-560.0,
503.0
],
"24": [
-231.0,
586.0
],
"31": [
-413.0,
644.0
],
"32": [
-964.0,
877.0
],
"33": [
-557.0,
752.0
],
"34": [
-1580.6732177734375,
-252.9154052734375
],
"37": [
-938.6732788085938,
-13.9154052734375
],
"39": [
-1082.0,
58.0
],
"4": [
-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
],
"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": [
367.0,
540.0
]
},
"previewOriginX": 0,
"previewOriginY": 0,
"previewScale": 0.5,
"seed": 0,
"worldOutputPasses": [
9,
18,
48,
62,
24
]
},
"graph": {
"connections": [
{
"from": 4,
"slot": 0,
"to": 5
},
{
"from": 6,
"slot": 1,
"to": 5
},
{
"from": 5,
"slot": 0,
"to": 9
},
{
"from": 7,
"slot": 1,
"to": 9
},
{
"from": 12,
"slot": 0,
"to": 15
},
{
"from": 16,
"slot": 1,
"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": 50,
"slot": 0,
"to": 31
},
{
"from": 33,
"slot": 1,
"to": 31
},
{
"from": 21,
"slot": 0,
"to": 33
},
{
"from": 54,
"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
},
{
"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": 77,
"nodes": [
{
"frequency": 0.07880000025033951,
"id": 4,
"type": "SimplexNoise"
},
{
"id": 5,
"type": "Greater"
},
{
"id": 6,
"type": "Constant",
"value": 0.17000000178813934
},
{
"id": 7,
"tileId": 1,
"type": "TileID"
},
{
"id": 9,
"type": "IntBranch"
},
{
"id": 12,
"maxX": 0,
"maxY": 2,
"minX": 0,
"minY": 0,
"tileId": 0,
"type": "QueryRange"
},
{
"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": 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": 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
},
{
"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"
}
]
}
}

100
data/WorldGraphs/stone.json Normal file
View File

@@ -0,0 +1,100 @@
{
"editor": {
"nodePositions": {
"13": [
-220.0,
24.0
],
"15": [
-77.0,
8.0
],
"16": [
-223.0,
206.0
],
"17": [
-190.0,
-250.0
],
"2": [
112.0,
-289.0
],
"4": [
-360.0,
41.0
],
"__worldOutput": [
400.0,
0.0
]
},
"previewOriginX": 0,
"previewOriginY": 0,
"previewScale": 0.4000000059604645,
"seed": 0,
"worldOutputPasses": [
2
]
},
"graph": {
"connections": [
{
"from": 15,
"slot": 0,
"to": 2
},
{
"from": 17,
"slot": 2,
"to": 2
},
{
"from": 4,
"slot": 0,
"to": 13
},
{
"from": 13,
"slot": 0,
"to": 15
},
{
"from": 16,
"slot": 1,
"to": 15
}
],
"nextId": 18,
"nodes": [
{
"id": 2,
"type": "IntBranch"
},
{
"frequency": 0.10000000149011612,
"id": 4,
"type": "SimplexNoise"
},
{
"id": 13,
"type": "Abs"
},
{
"id": 15,
"type": "Less"
},
{
"id": 16,
"type": "Constant",
"value": 0.3199999928474426
},
{
"id": 17,
"tileId": 1,
"type": "TileID"
}
]
}
}

58
data/tiles.json Normal file
View File

@@ -0,0 +1,58 @@
{
"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"
},
{
"color": [
0.5555469989776611,
0.15063799917697906,
0.8152173757553101
],
"id": 4,
"name": "Water"
},
{
"color": [
1.0,
0.782608687877655,
0.0
],
"id": 5,
"name": "Clay"
}
]
}

101
imgui.ini Normal file
View File

@@ -0,0 +1,101 @@
[Window][Node Canvas]
Pos=200,42
Size=1080,678
[Window][Settings]
Pos=0,42
Size=200,678
[Window][Debug##Default]
Pos=60,60
Size=400,400
[Window][Save Graph##SaveFileDlg]
Pos=323,94
Size=600,400
[Window][Open Graph##OpenFileDlg]
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
[Table][0x837FA078,4]
RefScale=13
Column 0 Sort=0v
[Table][0x80A59430,4]
RefScale=13
Column 0 Sort=0v
[Table][0xF2EF1CF5,4]
RefScale=13
Column 0 Sort=0v
[Table][0x3E490810,4]
RefScale=13
Column 0 Sort=0v
[Table][0x4546A666,4]
RefScale=13
Column 0 Sort=0v
[Table][0xFE9B7E06,4]
RefScale=13
Column 0 Sort=0v
[Table][0xD2055CEE,4]
RefScale=13
Column 0 Sort=0v
[Table][0xCDBCCF58,4]
RefScale=13
Column 0 Sort=0v
[Table][0x9E317450,4]
RefScale=13
Column 0 Sort=0v
[Table][0x26C1F0A6,4]
RefScale=13
Column 0 Sort=0v
[Table][0x5DDF70E5,4]
RefScale=13
Column 0 Sort=0v
[Table][0x4AC5837A,4]
RefScale=13
Column 0 Sort=0v
[Table][0xFB4E8B6C,4]
RefScale=13
Column 0 Sort=0v
[Table][0x8F6E2C61,4]
RefScale=13
Column 0 Sort=0v
[Table][0x42C29929,4]
RefScale=13
Column 0 Sort=0v
[Table][0x8FD0E34F,4]
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

@@ -1,30 +0,0 @@
#pragma once
#include "WorldGraphVisualNode.h"
class WorldGraph final
{
public:
WorldGraph() = default;
WorldGraph(const Vector<Ref<WorldGraphVisualNodeBase>>& nodes);
WorldGraph(const WorldGraph& other);
WorldGraph(WorldGraph&& other) noexcept = default;
WorldGraph& operator=(const WorldGraph& other);
WorldGraph& operator=(WorldGraph&& other) noexcept = default;
public:
Variant Execute(Ref<WorldGraphVisualNodeBase> node, const WorldNodeParameters& params) const;
WorldNodeBase* GetNode(Ref<WorldGraphVisualNodeBase> node) const;
private:
void Compile(const Vector<Ref<WorldGraphVisualNodeBase>>& nodes);
std::unique_ptr<WorldNodeBase*[]> CopyMemory(HashMap<Ref<WorldGraphVisualNodeBase>, WorldNodeBase*>& nodeMap) const;
private:
int MemorySize{};
std::unique_ptr<WorldNodeBase*[]> CompiledData{};
HashMap<Ref<WorldGraphVisualNodeBase>, WorldNodeBase*> NodeMap{};
};

View File

@@ -1,70 +0,0 @@
#pragma once
#include <cstdint>
#include <memory>
#include "modules/factory/include/Util/Span.h"
struct WorldGraphAllocatorBase
{
virtual ~WorldGraphAllocatorBase() = default;
virtual void* Allocate(tcb::span<const uint8_t> data) = 0;
virtual void Clear() = 0;
template <typename T>
T* Allocate(const T& val)
{
return static_cast<T*>(Allocate(tcb::span<const uint8_t>(reinterpret_cast<uint8_t const*>(&val), sizeof(T))));
}
};
struct WorldGraphAllocator : public WorldGraphAllocatorBase
{
virtual ~WorldGraphAllocator() = default;
WorldGraphAllocator() = default;
WorldGraphAllocator(uint32_t totalSize) : Data {std::make_unique<uint8_t[]>(totalSize)}, Size{totalSize} {}
virtual void* Allocate(tcb::span<const uint8_t> data) override
{
auto size = (data.size_bytes() + sizeof(void*) - 1) / sizeof(void*) * sizeof(void*); // make sure aligment is 8/4 bytes for pointers
if (CurrentOffset + size > Size)
throw std::exception{};
std::memcpy(Data.get() + CurrentOffset, data.data(), data.size_bytes());
CurrentOffset += size;
return Data.get() + CurrentOffset - size;
}
virtual void Clear() override
{
CurrentOffset = 0;
}
void* GetCurrentAddress() const
{
return Data.get() + CurrentOffset;
}
std::unique_ptr<uint8_t[]> Data{};
uint32_t Size{};
uint32_t CurrentOffset{};
};
struct WorldGraphSizeMeasurer : public WorldGraphAllocatorBase
{
virtual ~WorldGraphSizeMeasurer() = default;
WorldGraphSizeMeasurer() = default;
virtual void* Allocate(tcb::span<const uint8_t> data) override
{
TotalSize += (data.size_bytes() + sizeof(void*) - 1) / sizeof(void*) * sizeof(void*);
return nullptr;
}
virtual void Clear() override
{
TotalSize = 0;
}
uint32_t TotalSize{};
};

View File

@@ -1,665 +0,0 @@
#pragma once
#include <tuple>
#include <type_traits>
#include <utility>
#include <functional>
#include <array>
#include "WorldGraphAllocator.h"
#include "Util/FastNoiseLite.h"
#include "core/variant/variant.h"
#include "modules/factory/include/Core/Chunk.h"
// // last bit determines if it is a boolean or a float
// // if it is a float, last bit of precision will be lost (not that bad)
// // if it is a bool, the bool value will be stored on the second bit
// // for floats the last bit is set
// // for bools the last bit is unset
// class BoolFloat
// {
// public:
// BoolFloat() = default;
// BoolFloat(bool val) {}
// BoolFloat(float val) {}
// public:
// void SetFloat(float val) { Data = reinterpret_cast<uint32_t&>(val) | 0b1u; }
// void SetBool(bool val) { Data = (val << 1) & (~0b1u); }
// constexpr float IsFloat() const { return Data & 0b1u; }
// constexpr float IsBool() const { return Data & (~0b1u); }
// float GetFloat() const { _ASSERT(IsFloat()); return reinterpret_cast<const float&>(Data); }
// float GetBool() const { _ASSERT(IsBool()); return Data; }
// public:
// operator float() const { return GetFloat(); }
// private:
// uint32_t Data{};
// };
struct WorldNodeParameters;
struct alignas(void*) WorldNodeBase
{
virtual Variant Evaluate(const WorldNodeParameters& params) const = 0;
virtual Variant::Type GetReturnType() const = 0;
virtual Vector<Variant::Type> GetInputTypes() const = 0;
virtual bool IsValid() const = 0;
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const = 0;
virtual void SetInput(int index, WorldNodeBase* input) = 0;
};
struct WorldNodeParameters
{
static constexpr int MaxQueryOffset = 4;
static constexpr int PaddedChunkSide = Chunk::ChunkSize + MaxQueryOffset * 2;
static constexpr int PaddedChunkSize = PaddedChunkSide * PaddedChunkSide;
typedef std::array<Tile, PaddedChunkSize> TileArray;
int X{ };
int Y{ };
int Seed{ };
float FinalValueSubstract{ };
ChunkKey ChunkInfo{ };
TileArray* GeneratedTiles{ };
Tile GetTile(int x, int y) const
{
auto bounds = GetGenerationBounds();
if (unlikely(!bounds.has_point(Vector2i{x, y})))
return {};
// DEV_ASSERT(bounds.has_point(Vector2i{x, y}));
return (*GeneratedTiles)[(y - bounds.position.y) * PaddedChunkSide + (x - bounds.position.x)];
}
static int GetArrayIndex(int x, int y)
{
return (y + MaxQueryOffset) * PaddedChunkSide + (x + MaxQueryOffset);
}
Rect2i GetGenerationBounds() const
{
return ChunkInfo.GetBounds().grow(MaxQueryOffset);
}
};
template <typename T>
struct TtoVariant
{
typedef Variant TVariant;
};
template <typename Return, typename ... Inputs>
struct WorldNodeTemplated : public WorldNodeBase
{
std::array<WorldNodeBase*, sizeof...(Inputs)> InputNodes{};
virtual Variant::Type GetReturnType() const override { return Variant::get_type_t<Return>(); }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { Variant::get_type_t<Inputs>()... };
};
virtual bool IsValid() const override
{
bool valid{ true };
for (auto input : InputNodes)
{
valid = valid && input;
}
return valid;
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override { Allocator->Allocate(*this); }
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
std::array<Variant, sizeof...(Inputs)> results{};
for (int i{}; i < sizeof...(Inputs); ++i)
{
results[i] = InputNodes[i]->Evaluate(params);
}
auto EvaluateFunctor = [this](typename TtoVariant<Inputs>::TVariant... args) -> Variant
{
return EvaluateT(args.get_unsafe_t<Inputs>()...);
};
return std::apply(EvaluateFunctor, results);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
InputNodes[index] = input;
}
virtual Return EvaluateT(Inputs...) const = 0;
};
struct WorldNode_Add : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return v0 + v1;
}
};
struct WorldNode_Subtract : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return v0 - v1;
}
};
struct WorldNode_Multiply : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return v0 * v1;
}
};
struct WorldNode_Divide : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return v0 / v1;
}
};
struct WorldNode_Modulo : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return fmod(v0, v1);
}
};
struct WorldNode_Equal : public WorldNodeTemplated<bool, float, float>
{
virtual bool EvaluateT(float v0, float v1) const override
{
return v0 == v1;
}
};
struct WorldNode_Smaller : public WorldNodeTemplated<bool, float, float>
{
virtual bool EvaluateT(float v0, float v1) const override
{
return v0 < v1;
}
};
struct WorldNode_Greater : public WorldNodeTemplated<bool, float, float>
{
virtual bool EvaluateT(float v0, float v1) const override
{
return v0 > v1;
}
};
struct WorldNode_SmallerEqual : public WorldNodeTemplated<bool, float, float>
{
virtual bool EvaluateT(float v0, float v1) const override
{
return v0 <= v1;
}
};
struct WorldNode_GreaterEqual : public WorldNodeTemplated<bool, float, float>
{
virtual bool EvaluateT(float v0, float v1) const override
{
return v0 >= v1;
}
};
struct WorldNode_Negate : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return -v;
}
};
struct WorldNode_Abs : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::abs(v);
}
};
struct WorldNode_Ceil : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::ceil(v);
}
};
struct WorldNode_Floor : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::floor(v);
}
};
struct WorldNode_Sin : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::sin(v);
}
};
struct WorldNode_Cos : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::cos(v);
}
};
struct WorldNode_Tan : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::tan(v);
}
};
struct WorldNode_Exp : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::exp(v);
}
};
struct WorldNode_Pow : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return std::pow(v0, v1);
}
};
struct WorldNode_Max : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return std::max(v0, v1);
}
};
struct WorldNode_Min : public WorldNodeTemplated<float, float, float>
{
virtual float EvaluateT(float v0, float v1) const override
{
return std::min(v0, v1);
}
};
struct WorldNode_Clamp : public WorldNodeTemplated<float, float, float, float>
{
virtual float EvaluateT(float v0, float v1, float v2) const override
{
return std::clamp(v0, v1, v2);
}
};
struct WorldNode_Round : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::round(v);
}
};
struct WorldNode_Log : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return std::log(v);
}
};
struct WorldNode_Square : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float v) const override
{
return v * v;
}
};
struct WorldNode_Lerp : public WorldNodeTemplated<float, float, float, float>
{
virtual float EvaluateT(float from, float to, float weight) const override
{
return Math::lerp(from, to, weight);
}
};
struct WorldNode_OneMinus : public WorldNodeTemplated<float, float>
{
virtual float EvaluateT(float val) const override
{
return 1 - val;
}
};
struct WorldNode_And : public WorldNodeTemplated<bool, bool, bool>
{
virtual bool EvaluateT(bool val0, bool val1) const override
{
return val0 && val1;
}
};
struct WorldNode_Or : public WorldNodeTemplated<bool, bool, bool>
{
virtual bool EvaluateT(bool val0, bool val1) const override
{
return val0 || val1;
}
};
struct WorldNode_Constant : public WorldNodeBase
{
float Value{};
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
return Value;
}
virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { };
};
virtual bool IsValid() const override
{
return !std::_Is_nan(Value);
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override
{
Allocator->Allocate(*this);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
DEV_ASSERT(false);
}
};
struct WorldNode_Branch : public WorldNodeBase
{
WorldNodeBase* InputBool{};
WorldNodeBase* InputTrue{};
WorldNodeBase* InputFalse{};
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
bool condition = InputBool->Evaluate(params).get_unsafe_bool();
return condition ? InputTrue->Evaluate(params) : InputFalse->Evaluate(params);
}
virtual Variant::Type GetReturnType() const override { return Variant::Type::FLOAT; }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { Variant::Type::BOOL, Variant::Type::FLOAT, Variant::Type::FLOAT };
};
virtual bool IsValid() const override
{
return InputBool && InputTrue && InputFalse;
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override
{
Allocator->Allocate(*this);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
switch (index)
{
case 0: InputBool = input;
case 1: InputTrue = input;
case 2: InputFalse = input;
}
}
};
struct WorldNode_NoiseBase : public WorldNodeBase
{
float Frequency{ 1.f };
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
float x = params.X * Frequency;
float y = params.Y * Frequency;
return EvaluateNoise(params.Seed, x, y);
}
virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { };
};
virtual bool IsValid() const override
{
return Frequency != 0;
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override
{
Allocator->Allocate(*this);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
DEV_ASSERT(false);
}
virtual float EvaluateNoise(int seed, float x, float y) const = 0;
};
struct WorldNode_Simplex : public WorldNode_NoiseBase
{
virtual float EvaluateNoise(int seed, float x, float y) const override
{
const float SQRT3 = (float)1.7320508075688772935274463415059;
const float F2 = 0.5f * (SQRT3 - 1);
float t = (x + y) * F2;
x += t;
y += t;
return fastnoiselitestatic::SingleSimplex<float>(seed, x, y);
}
};
struct WorldNode_OpenSimplex : public WorldNode_NoiseBase
{
virtual float EvaluateNoise(int seed, float x, float y) const override
{
const float SQRT3 = (float)1.7320508075688772935274463415059;
const float F2 = 0.5f * (SQRT3 - 1);
float t = (x + y) * F2;
x += t;
y += t;
return fastnoiselitestatic::SingleOpenSimplex2S<float>(seed, x, y);
}
};
struct WorldNode_Perlin : public WorldNode_NoiseBase
{
virtual float EvaluateNoise(int seed, float x, float y) const override
{
return fastnoiselitestatic::SinglePerlin<float>(seed, x, y);
}
};
struct WorldNode_ValueCubic : public WorldNode_NoiseBase
{
virtual float EvaluateNoise(int seed, float x, float y) const override
{
return fastnoiselitestatic::SingleValueCubic<float>(seed, x, y);
}
};
struct WorldNode_Value : public WorldNode_NoiseBase
{
virtual float EvaluateNoise(int seed, float x, float y) const override
{
return fastnoiselitestatic::SingleValue<float>(seed, x, y);
}
};
struct WorldNode_IsTile : public WorldNodeBase
{
int8_t RelativeX{};
int8_t RelativeY{};
TILE_TYPE TileType{};
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
return params.GetTile(params.X + RelativeX, params.Y + RelativeY).GetType() == TileType;
}
virtual Variant::Type GetReturnType() const override { return Variant::BOOL; }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { };
};
virtual bool IsValid() const override
{
return true;
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override
{
Allocator->Allocate(*this);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
DEV_ASSERT(false);
}
};
struct WorldNode_TileDistance : public WorldNodeBase
{
int8_t Range{};
TILE_TYPE TileType{};
private:
struct SmallVectorI2{
int8_t X; int8_t Y;
SmallVectorI2(int8_t x, int8_t y) : X{ x }, Y{ y } {};
SmallVectorI2() = default;
};
static constexpr int ArraySize{(WorldNodeParameters::MaxQueryOffset * 2 + 1) * (WorldNodeParameters::MaxQueryOffset * 2 + 1) - 1};
static std::array<SmallVectorI2, ArraySize> CreateSortedOffsets()
{
std::array<SmallVectorI2, ArraySize> offsets{};
int counter{};
for (int y{-WorldNodeParameters::MaxQueryOffset}; y <= WorldNodeParameters::MaxQueryOffset; ++y)
for (int x{-WorldNodeParameters::MaxQueryOffset}; x <= WorldNodeParameters::MaxQueryOffset; ++x)
if (y != 0 && x != 0)
{
offsets[counter] = SmallVectorI2{ static_cast<int8_t>(x), static_cast<int8_t>(y) };
++counter;
}
std::sort(offsets.begin(), offsets.end(), [] (SmallVectorI2 lhs, SmallVectorI2 rhs)
{
return rhs.X * rhs.X + rhs.Y * rhs.Y > lhs.X * lhs.X + lhs.Y * lhs.Y;
});
return offsets;
}
inline static const std::array<SmallVectorI2, ArraySize> SortedOffsets{ CreateSortedOffsets() };
static std::array<int, WorldNodeParameters::MaxQueryOffset> CreateRangeOffsets()
{
std::array<int, WorldNodeParameters::MaxQueryOffset> offsets{};
for (int i{}; i < WorldNodeParameters::MaxQueryOffset; ++i)
{
offsets[i] = ArraySize - (((WorldNodeParameters::MaxQueryOffset - i) * 2 + 1) * ((WorldNodeParameters::MaxQueryOffset - i) * 2 + 1) - 1);
}
return offsets;
}
inline static const std::array<int, WorldNodeParameters::MaxQueryOffset> RangeOffsets{ CreateRangeOffsets() };
// inline static const SmallVectorI2 SortedOffset[] =
// {
// {+3, +3}, {-3, -3}, {+3, -3}, {-3, +3}, // 3
// {+2, +3}, {+3, +2}, {-2, -3}, {-3, -2}, {-2, +3}, {-3, +2}, {+2, -3}, {+3, -2},
// {+1, +3}, {+3, +1}, {-1, -3}, {-3, -1}, {-1, +3}, {-3, +1}, {+1, -3}, {+3, -1},
// {+3, +0}, {-3, -0}, {+0, -3}, {-0, +3},
// {+2, +2}, {-2, -2}, {+2, -2}, {-2, +2}, // 2
// {+1, +2}, {+2, +1}, {-1, -2}, {-2, -1}, {-1, +2}, {-2, +1}, {+1, -2}, {+2, -1},
// {+2, +0}, {-2, -0}, {+0, -2}, {-0, +2},
// {+1, +1}, {-1, -1}, {+1, -1}, {-1, +1}, // 1
// {+1, +0}, {-1, -0}, {+0, -1}, {-0, +1},
// };
// inline static const int8_t RangeOffsets[] =
// {
// 0, 24, 40
// };
public:
virtual Variant Evaluate(const WorldNodeParameters& params) const override
{
if (!params.ChunkInfo.GetBounds().has_point(Vector2i{params.X, params.Y})) return 16'384.f;
int maxRangeSQ = 16'384;
for (int i{RangeOffsets[Range]}; i < 48; ++i)
{
auto offset = SortedOffsets[i];
if (params.GetTile(params.X + offset.X, params.Y + offset.Y).GetType() == TileType)
{
maxRangeSQ = offset.X * offset.X + offset.Y * offset.Y;
}
}
return sqrtf(static_cast<float>(maxRangeSQ));
}
virtual Variant::Type GetReturnType() const override { return Variant::FLOAT; }
virtual Vector<Variant::Type> GetInputTypes() const override
{
return { };
};
virtual bool IsValid() const override
{
return Range >= 1 && Range <= 3;
}
virtual void Allocate(WorldGraphAllocatorBase* Allocator) const override
{
Allocator->Allocate(*this);
}
virtual void SetInput(int index, WorldNodeBase* input) override
{
DEV_ASSERT(false);
}
};
// struct WorldNode_Cellular : public WorldNode_NoiseBase
// {
// virtual float EvaluateNoise(int seed, float x, float y) const override
// {
// const float SQRT3 = (float)1.7320508075688772935274463415059;
// const float F2 = 0.5f * (SQRT3 - 1);
// float t = (x + y) * F2;
// x += t;
// y += t;
// return fastnoiselite::FastNoiseLite::SingleCellular<float>(seed, x, y);
// }
// };

View File

@@ -1,174 +0,0 @@
#pragma once
#include "WorldGraphNode.h"
#include "core/io/resource.h"
#include "modules/factory/include/Util/Helpers.h"
class WorldGraphVisualNodeBase : public Resource
{
GDCLASS(WorldGraphVisualNodeBase, Resource);
public:
static void _bind_methods();
public:
virtual ~WorldGraphVisualNodeBase() = default;
public:
Vector2i GetPosition() const { return Position; }
TypedArray<WorldGraphVisualNodeBase> GetInputs() const
{
return VectorToTypedArray(InputNodes);
}
void SetPosition(Vector2i pos) { Position = pos; }
void SetInputNodes(TypedArray<WorldGraphVisualNodeBase> inputNodes)
{
InputNodes = TypedArrayToVector(inputNodes);
RefreshInputs();
}
bool NodeIsValid() const { return IsValid(); }
TypedArray<int> NodeGetInputTypes() const { return VectorToTypedArrayCast<int>(GetInputTypes()); }
int NodeGetOutputType() const { return GetOutputType(); }
void NodeSetInput(int index, Ref<WorldGraphVisualNodeBase> input) { SetInput(index, input); }
bool HasInternalNode() const { return InternalNode.get(); };
bool CanExecuteNode();
void SetInternalNode(std::unique_ptr<WorldNodeBase>&& node);
WorldNodeBase* GetInternalNode() const { return InternalNode.get(); }
void RefreshInputs();
public:
virtual Vector<Variant::Type> GetInputTypes() const { return InternalNode ? InternalNode->GetInputTypes() : Vector<Variant::Type>{}; };
virtual Variant::Type GetOutputType() const { return InternalNode ? InternalNode->GetReturnType() : Variant::Type{}; };
virtual void SetInput(int index, Ref<WorldGraphVisualNodeBase> input)
{
InputNodes.set(index, input);
InternalNode->SetInput(index, input.is_valid() ? input->InternalNode.get() : nullptr);
}
virtual bool IsValid() const
{
if (!InternalNode)
print_error(String("No internal node for ") + get_class_name());
if (!InternalNode->IsValid())
print_error(String("node is invalid ") + get_class_name());
return InternalNode && InternalNode->IsValid();
}
virtual void RefreshValues() {};
public:
Vector2i Position{};
Vector<Ref<WorldGraphVisualNodeBase>> InputNodes{};
private:
std::unique_ptr<WorldNodeBase> InternalNode{};
};
class WorldGraphVisualNode_Math : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_Math, WorldGraphVisualNodeBase);
public:
static void _bind_methods();
public:
WorldGraphVisualNode_Math();
virtual ~WorldGraphVisualNode_Math() = default;
public:
TypedArray<String> GetNodeNames() const;
void SetNode(String nodeName);
String GetNode() const { return NodeID; }
private:
String NodeID{};
};
class WorldGraphVisualNode_Constant : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_Constant, WorldGraphVisualNodeBase);
public:
static void _bind_methods();
public:
WorldGraphVisualNode_Constant();
virtual ~WorldGraphVisualNode_Constant() = default;
private:
void SetValue(float val);
float GetValue() const;
};
class WorldGraphVisualNode_If : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_If, WorldGraphVisualNodeBase);
public:
static void _bind_methods() {};
public:
WorldGraphVisualNode_If();
virtual ~WorldGraphVisualNode_If() = default;
};
class WorldGraphVisualNode_Noise : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_Noise, WorldGraphVisualNodeBase);
public:
static void _bind_methods();
public:
void SetNoiseType(String noiseType);
String GetNoiseType() const { return NoiseType; }
float GetFrequency() const { return Frequency; }
TypedArray<String> GetNoiseTypes() const;
void SetFrequency(float val);
virtual void RefreshValues() override;
public:
WorldGraphVisualNode_Noise();
virtual ~WorldGraphVisualNode_Noise() = default;
private:
String NoiseType{};
float Frequency{};
};
class WorldGraphVisualNode_Tile : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_Tile, WorldGraphVisualNodeBase);
public:
static void _bind_methods();
public:
WorldGraphVisualNode_Tile();
virtual ~WorldGraphVisualNode_Tile() = default;
public:
void SetType(int type);
void SetRelativeX(int offset);
void SetRelativeY(int offset);
int GetType() const;
int GetRelativeX() const;
int GetRelativeY() const;
};
class WorldGraphVisualNode_TileDistance : public WorldGraphVisualNodeBase
{
GDCLASS(WorldGraphVisualNode_TileDistance, WorldGraphVisualNodeBase);
public:
static void _bind_methods();
public:
WorldGraphVisualNode_TileDistance();
virtual ~WorldGraphVisualNode_TileDistance() = default;
public:
void SetType(int type);
void SetRange(int range);
int GetType() const;
int GetRange() const;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,213 @@
# WorldGraph
A node-based procedural tile generation system. Graphs are composed of typed nodes wired together; evaluating the graph at a given world cell produces a tile ID.
---
## Overview
World generation is expressed as a **directed acyclic graph (DAG)** of computation nodes. Each node takes zero or more typed inputs, performs some computation, and produces a single typed output. The graph is evaluated independently **per cell** — there is no shared mutable state between cells.
The final result is an integer **tile ID**, obtained by calling `AsInt()` on whatever value the output node produces.
---
## Core Types (`WorldGraphTypes.h`)
### `Value`
A tagged union that can hold an `Int`, `Float`, or `Bool`. Values are coerced automatically when a node requests a different type (e.g. `AsFloat()` on an Int just casts it). This means connections between different types are permissive at runtime, though the editor can flag them.
### `EvalContext`
The per-cell context forwarded to every node during evaluation:
| Field | Description |
|---|---|
| `worldX`, `worldY` | World-space tile coordinates of the cell being generated |
| `seed` | World seed for deterministic noise |
| `prevTiles` | Flat row-major array of tile IDs from the previous generation pass |
| `prevOriginX/Y`, `prevWidth/Height` | Position and size of the previous pass buffer |
`GetPrevTile(x, y)` safely reads the previous pass at any absolute world coordinate, returning `0` (AIR) for out-of-bounds or when no previous pass exists.
---
## Graph (`WorldGraph.h`)
`Graph` owns a set of nodes and the connections between them.
```
NodeID AddNode(unique_ptr<Node>) — add a node, returns its ID
bool Connect(from, to, inputSlot) — wire an output to an input
Value Evaluate(outputNode, ctx) — evaluate for one cell
bool IsValid(outputNode) — check all inputs are wired
```
**Cycle detection** runs at `Connect()` time. Any connection that would form a cycle is rejected and returns `false`.
**Evaluation** is recursive and on-demand. Starting from the output node, the graph walks upstream, evaluating each dependency before the node that depends on it. Unconnected inputs receive a zero value of their declared type.
---
## Nodes (`WorldGraphNode.h`)
All nodes derive from the abstract `Node` base class and implement:
- `GetOutputType()` — the type this node produces
- `GetInputTypes()` — the types of each input slot
- `Evaluate(ctx, inputs)` — computes and returns the output value
### Source Nodes (no inputs)
| Node | Output | Description |
|---|---|---|
| `ConstantNode` | Float | A fixed float value |
| `IDNode` | Int | A fixed tile ID integer |
| `PositionXNode` | Int | World X coordinate of the current cell |
| `PositionYNode` | Int | World Y coordinate of the current cell |
### Math Nodes
| Node | Inputs | Output | Description |
|---|---|---|---|
| `AddNode` | Float, Float | Float | a + b |
| `SubtractNode` | Float, Float | Float | a b |
| `MultiplyNode` | Float, Float | Float | a × b |
| `DivideNode` | Float, Float | Float | a / b (0 on divide-by-zero) |
| `ModuloNode` | Float, Float | Float | fmod(a, b) |
| `SinNode` | Float | Float | sin(a) in radians |
| `CosNode` | Float | Float | cos(a) in radians |
### Comparison Nodes
All produce `Bool`. Inputs are Float.
`LessNode`, `GreaterNode`, `LessEqualNode`, `GreaterEqualNode`, `EqualNode`
> **Note:** `EqualNode` compares floats directly — use only with integer-valued inputs.
### Boolean Logic Nodes
| Node | Inputs | Output |
|---|---|---|
| `AndNode` | Bool, Bool | Bool |
| `OrNode` | Bool, Bool | Bool |
| `NotNode` | Bool | Bool |
### Control Flow
`BranchNode` — selects between two values based on a condition:
```
inputs[0] Bool — condition
inputs[1] Float — value if true
inputs[2] Float — value if false
```
The concrete type of the chosen branch is passed through, so an `IDNode` connected to the true/false slot will produce an `Int` even though `GetOutputType()` reports `Float`.
### Previous-Pass Query Nodes
These nodes read from the previous generation pass via `EvalContext`. They have **no inputs** — their parameters are baked into the node at construction time. The chunk generator uses these baked offsets to pre-compute how much border padding the previous pass must produce.
| Node | Output | Description |
|---|---|---|
| `QueryTileNode(offsetX, offsetY, expectedID)` | Bool | `true` if the tile at `(worldX+offsetX, worldY+offsetY)` equals `expectedID` |
| `QueryRangeNode(minX, minY, maxX, maxY, tileID)` | Int | Count of tiles matching `tileID` within the relative rectangle |
| `QueryDistanceNode(tileID, maxDistance)` | Int | Chebyshev distance to the nearest tile matching `tileID`; returns `maxDistance + 1` if none found. The current cell is excluded. |
---
## Multi-Pass Chunk Generation (`WorldGraphChunk.h`)
Complex terrain is built in **ordered passes**, where each pass can read the output of the previous one. For example: pass 1 places stone, pass 2 adds ores based on nearby stone.
### `GenerationPass`
A thin struct pairing a `Graph` with the `NodeID` of its output node.
### Zero = "No Change"
When a pass outputs `0` for a cell, it means **keep the previous pass's value** (or AIR if there was no previous pass). This lets later passes act as selective overrides rather than full rewrites.
### `GenerateRegion`
Generates a single rectangular region using one graph. Walks every cell, builds an `EvalContext` pointing at `prevPassData`, evaluates the graph, and writes the result.
### `GenerateChunk`
Runs a full sequence of passes over a chunk, automatically computing the padding each pass needs.
```
Algorithm:
1. The last pass generates exactly [chunkOriginX, chunkOriginY, W × H].
2. Working backwards from the last pass, ComputeRequiredPadding inspects
every QueryTile / QueryRange / QueryDistance node in each pass's graph
to determine how much the previous pass's output region must expand.
This expansion accumulates from last pass to first.
3. Passes execute in forward order. Each feeds its TileGrid output to the
next pass as prevPassData.
4. The final TileGrid covers the original chunk region only.
```
### `PaddingBounds`
Records how many extra tiles are needed beyond the output region in each direction (`negX`, `posX`, `negY`, `posY`). Computed automatically by `ComputeRequiredPadding`, which walks the graph and unions the baked offsets from all query nodes.
---
## Serialization (`WorldGraphSerializer.h`)
`GraphSerializer` converts a `Graph` to/from JSON (nlohmann/json).
```cpp
nlohmann::json j = GraphSerializer::ToJson(graph);
optional<Graph> g = GraphSerializer::FromJson(j);
bool ok = GraphSerializer::Save(graph, "path/to/file.json");
optional<Graph> g2 = GraphSerializer::Load("path/to/file.json");
```
JSON structure:
```json
{
"nextId": 5,
"nodes": [
{ "id": 1, "type": "Constant", "value": 0.5 },
{ "id": 2, "type": "TileID", "tileId": 3 },
{ "id": 3, "type": "Branch" }
],
"connections": [
{ "from": 1, "to": 3, "slot": 0 },
{ "from": 2, "to": 3, "slot": 1 }
]
}
```
Only `Constant` and `TileID` nodes carry extra fields. All query nodes (`QueryTile`, `QueryRange`, `QueryDistance`) serialize their baked offset/ID parameters.
---
## Minimal Usage Example
```cpp
using namespace WorldGraph;
// Build a graph that places tile 2 when Y < -10, otherwise tile 1
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto thresh = g.AddNode(std::make_unique<ConstantNode>(-10.0f));
auto cond = g.AddNode(std::make_unique<LessNode>());
auto tileA = g.AddNode(std::make_unique<IDNode>(2)); // underground tile
auto tileB = g.AddNode(std::make_unique<IDNode>(1)); // surface tile
auto branch = g.AddNode(std::make_unique<BranchNode>());
g.Connect(posY, cond, 0); // posY → Less.a
g.Connect(thresh, cond, 1); // -10 → Less.b
g.Connect(cond, branch, 0); // bool → Branch.condition
g.Connect(tileA, branch, 1); // tile2 → Branch.true
g.Connect(tileB, branch, 2); // tile1 → Branch.false
// Generate a 64×64 chunk at world position (0, -32)
GenerationPass pass { g, branch };
TileGrid chunk = GenerateChunk({ pass }, 0, -32, 64, 64, /*seed=*/12345);
```

View File

@@ -0,0 +1,114 @@
#pragma once
#include "WorldGraph/WorldGraphNode.h"
#include <memory>
#include <optional>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace WorldGraph {
class GraphSerializer;
/// Node-based graph for a single procedural world-generation pass.
///
/// Usage overview
/// ──────────────
/// 1. Call AddNode() for each node you need; keep the returned NodeIDs.
/// 2. Wire outputs to inputs with Connect(fromNode, toNode, inputSlot).
/// 3. Call Evaluate(outputNodeID, ctx) per cell to get its tile ID.
///
/// Evaluation is recursive and performed on the fly. Unconnected inputs
/// receive a zero value of their declared type; the caller is responsible
/// for checking IsValid() if all inputs must be wired.
///
/// Cycle detection runs at Connect() time: a connection that would create a
/// cycle is rejected and false is returned.
class Graph {
public:
using NodeID = uint32_t;
static constexpr NodeID INVALID_ID = 0;
// ── Node management ────────────────────────────────────────────────────
/// Add a node; returns its assigned ID (always > INVALID_ID).
NodeID AddNode(std::unique_ptr<Node> node);
/// Remove a node and sever every connection that references it.
void RemoveNode(NodeID id);
/// Look up a node by ID (returns nullptr if not found).
Node* GetNode(NodeID id);
const Node* GetNode(NodeID id) const;
size_t NodeCount() const { return nodes.size(); }
// ── Connection management ─────────────────────────────────────────────
/// Wire the output of \p fromNode into input slot \p inputSlot of \p toNode.
///
/// Returns false (and makes no change) when:
/// - either node is not in the graph, or
/// - the connection would create a directed cycle.
///
/// Connecting to an already-wired slot replaces the previous connection.
bool Connect(NodeID fromNode, NodeID toNode, int inputSlot);
/// Remove the wire going into \p toNode's \p inputSlot (no-op if not wired).
void Disconnect(NodeID toNode, int inputSlot);
/// Return the source node wired to (toNode, inputSlot), or nullopt.
std::optional<NodeID> GetInput(NodeID toNode, int inputSlot) const;
// ── Evaluation ────────────────────────────────────────────────────────
/// Recursively evaluate the subgraph rooted at \p outputNode for the cell
/// described by \p ctx. Call AsInt() on the result to obtain a tile ID.
///
/// Unconnected inputs default to zero. Returns a zero Float value if
/// \p outputNode is not in the graph.
Value Evaluate(NodeID outputNode, const EvalContext& ctx) const;
/// True when every required input of \p outputNode (and all its transitive
/// dependencies) is connected.
bool IsValid(NodeID outputNode) const;
/// Returns all node IDs reachable from (and including) \p outputNode.
/// Useful for inspecting query-node offsets to compute padding requirements.
std::vector<NodeID> GetDependencies(NodeID outputNode) const;
private:
friend class GraphSerializer;
NodeID nextID { 1 };
std::unordered_map<NodeID, std::unique_ptr<Node>> nodes;
// Connection map: (toNode, inputSlot) → fromNode
struct ConnKey {
NodeID toNode;
int slot;
bool operator==(const ConnKey& o) const noexcept {
return toNode == o.toNode && slot == o.slot;
}
};
struct ConnKeyHash {
size_t operator()(const ConnKey& k) const noexcept {
return std::hash<uint64_t>{}(
(static_cast<uint64_t>(k.toNode) << 32) | static_cast<uint32_t>(k.slot));
}
};
std::unordered_map<ConnKey, NodeID, ConnKeyHash> connections;
// Returns true if adding an edge (from → to) would create a cycle.
// This holds when 'to' is already a transitive dependency of 'from'.
bool WouldCreateCycle(NodeID from, NodeID to) const;
// Recursively collect 'id' and all its upstream dependencies into 'out'.
void CollectDependencies(NodeID id, std::unordered_set<NodeID>& out) const;
Value EvaluateImpl(NodeID id, const EvalContext& ctx) const;
};
} // namespace WorldGraph

View File

@@ -0,0 +1,116 @@
#pragma once
#include "WorldGraph/WorldGraph.h"
#include <cstdint>
#include <vector>
namespace WorldGraph {
// ─────────────────────────────── TileGrid ────────────────────────────────────
/// A 2D tile-ID array covering a contiguous world region.
///
/// Storage is row-major: data[ly * width + lx]
/// where lx = worldX - originX, ly = worldY - originY.
///
/// worldY increases "upward" (positive = sky, negative = underground).
struct TileGrid {
std::vector<int32_t> data;
int32_t originX { 0 };
int32_t originY { 0 };
int32_t width { 0 };
int32_t height { 0 };
TileGrid() = default;
TileGrid(int32_t ox, int32_t oy, int32_t w, int32_t h)
: data(static_cast<size_t>(w) * h, 0)
, originX(ox), originY(oy), width(w), height(h) {}
bool Contains(int32_t worldX, int32_t worldY) const noexcept;
/// Returns the tile ID at (worldX, worldY), or 0 if out of bounds.
int32_t Get(int32_t worldX, int32_t worldY) const noexcept;
/// Sets the tile at (worldX, worldY). No-op if out of bounds.
void Set(int32_t worldX, int32_t worldY, int32_t id) noexcept;
/// Build an EvalContext for cell (wx, wy) backed by this grid as prev-pass data.
EvalContext MakeEvalContext(int32_t wx, int32_t wy, uint64_t seed) const noexcept;
};
// ─────────────────────────────── PaddingBounds ───────────────────────────────
/// Extra tiles a pass needs beyond its output region from the previous pass,
/// in each compass direction.
struct PaddingBounds {
int32_t negX { 0 }; ///< Extra tiles needed toward X.
int32_t posX { 0 }; ///< Extra tiles needed toward +X.
int32_t negY { 0 }; ///< Extra tiles needed toward Y.
int32_t posY { 0 }; ///< Extra tiles needed toward +Y.
/// Expand to accommodate a single relative offset (dx, dy).
void Include(int32_t dx, int32_t dy) noexcept;
/// Expand to be at least as large as another PaddingBounds.
void Include(const PaddingBounds& o) noexcept;
int32_t TotalX() const noexcept { return negX + posX; }
int32_t TotalY() const noexcept { return negY + posY; }
bool IsZero() const noexcept { return negX == 0 && posX == 0 && negY == 0 && posY == 0; }
};
/// Walk the subgraph rooted at \p outputNode and return the maximum query offsets
/// found in any QueryTile / QueryRange / QueryDistance node.
///
/// This tells you how much border the PREVIOUS pass must generate so that every
/// query within this pass can be satisfied.
PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNode);
// ─────────────────────────────── Generation API ──────────────────────────────
/// One pass in a multi-pass world-generation pipeline.
struct GenerationPass {
const Graph& graph;
Graph::NodeID outputNode;
};
/// Generate a tile grid covering [originX .. originX+width-1] × [originY .. originY+height-1].
///
/// For each cell the graph is evaluated with a context that provides:
/// - The cell's world position.
/// - The full contents of \p prevPassData as the previous-pass tile source
/// (nullptr for the first pass).
///
/// Semantics of the return value:
/// - Non-zero → set that tile to the returned ID.
/// - Zero → "no change": if prevPassData is available the cell keeps its
/// previous-pass value; otherwise it stays 0 (AIR).
TileGrid GenerateRegion(
const Graph& graph,
Graph::NodeID outputNode,
int32_t originX, int32_t originY,
int32_t width, int32_t height,
const TileGrid* prevPassData,
uint64_t seed);
/// Run multiple passes over a chunk, automatically deriving the padded regions
/// each pass needs.
///
/// Algorithm
/// ─────────
/// 1. passes[last] generates exactly [chunkOriginX, chunkOriginY, W × H].
/// 2. For every earlier pass i, ComputeRequiredPadding(passes[i+1]) determines
/// how much larger passes[i]'s output must be so passes[i+1] can query it.
/// This expansion accumulates from last to first.
/// 3. Passes execute in forward order; each feeds its output to the next.
///
/// The returned TileGrid covers the final chunk region only.
TileGrid GenerateChunk(
const std::vector<GenerationPass>& passes,
int32_t chunkOriginX, int32_t chunkOriginY,
int32_t chunkWidth, int32_t chunkHeight,
uint64_t seed);
} // namespace WorldGraph

View File

@@ -0,0 +1,839 @@
#pragma once
#include "WorldGraph/WorldGraphTypes.h"
#include "config.h"
#include <FastNoiseLite.h>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <string>
#include <vector>
namespace WorldGraph {
// ─────────────────────────────── Node base ───────────────────────────────────
/// Abstract base for every node in the world-generation graph.
///
/// A node declares its output type and input types, and implements Evaluate()
/// to compute an output Value from its inputs and the per-cell EvalContext.
/// Type declarations are advisory — coercion is available on Value — but they
/// allow the future visual editor to flag mismatched connections.
class Node {
public:
virtual ~Node() = default;
virtual Type GetOutputType() const = 0;
virtual std::vector<Type> GetInputTypes() const = 0;
/// Compute the output value.
/// \p inputs is guaranteed to have exactly GetInputCount() entries.
virtual Value Evaluate(const EvalContext& ctx,
const std::vector<Value>& inputs) const = 0;
virtual std::string GetName() const = 0;
size_t GetInputCount() const { return GetInputTypes().size(); }
};
// ─────────────────────────────── Math nodes ──────────────────────────────────
/// Outputs a + b (Float)
class AddNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Add"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(in[0].AsFloat() + in[1].AsFloat());
};
};
/// Outputs a b (Float)
class SubtractNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Subtract"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(in[0].AsFloat() - in[1].AsFloat());
}
};
/// Outputs a × b (Float)
class MultiplyNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Multiply"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(in[0].AsFloat() * in[1].AsFloat());
}
};
/// Outputs a / b (Float; returns 0 on division by zero)
class DivideNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Divide"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
float b = in[1].AsFloat();
if (b == 0.0f) return Value::MakeFloat(0.0f);
return Value::MakeFloat(in[0].AsFloat() / b);
}
};
// ─────────────────────────────── Comparison nodes ────────────────────────────
/// Outputs true when a < b
class LessNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Less"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsFloat() < in[1].AsFloat());
}
};
/// Outputs true when a > b
class GreaterNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Greater"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsFloat() > in[1].AsFloat());
}
};
/// Outputs true when a <= b
class LessEqualNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "LessEqual"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsFloat() <= in[1].AsFloat());
}
};
/// Outputs true when a >= b
class GreaterEqualNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "GreaterEqual"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsFloat() >= in[1].AsFloat());
}
};
/// Outputs true when a == b (float comparison; use with care for non-integers)
class EqualNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Equal"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsFloat() == in[1].AsFloat());
}
};
// ─────────────────────────────── Boolean logic ───────────────────────────────
/// Outputs a && b
class AndNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "And"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsBool() && in[1].AsBool());
}
};
/// Outputs a || b
class OrNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "Or"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsBool() || in[1].AsBool());
}
};
/// Outputs !a
class NotNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool }; }
std::string GetName() const override { return "Not"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeBool(!in[0].AsBool());
}
};
// ─────────────────────────────── Control flow ─────────────────────────────────
/// Selects between two inputs based on a boolean condition.
///
/// inputs[0] condition (Bool)
/// inputs[1] value when true
/// inputs[2] value when false
///
/// The output Value preserves the concrete type of whichever branch is chosen,
/// so Int tile IDs pass through correctly even though GetOutputType() reports Float.
class BranchNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override {
return { Type::Bool, Type::Float, Type::Float };
}
std::string GetName() const override { return "Branch"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 3);
return in[0].AsBool() ? in[1] : in[2];
}
};
// ─────────────────────────────── More math nodes ─────────────────────────────
/// sin(a) in radians (Float)
class SinNode : 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 "Sin"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::sin(in[0].AsFloat()));
}
};
/// cos(a) in radians (Float)
class CosNode : 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 "Cos"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::cos(in[0].AsFloat()));
}
};
/// fmod(a, b) — returns 0 when b == 0 (Float)
class ModuloNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Modulo"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
float b = in[1].AsFloat();
if (b == 0.0f) return Value::MakeFloat(0.0f);
return Value::MakeFloat(std::fmod(in[0].AsFloat(), b));
}
};
// ─────────────────────────────── Tile query nodes ────────────────────────────
//
// Query nodes read from the PREVIOUS generation pass via EvalContext::GetPrevTile().
// Their offsets are baked into the node so the chunk generator can compute the
// required border padding BEFORE evaluation begins.
//
// Returns 0 (AIR / no data) for any out-of-bounds access.
/// Returns true when the previous-pass tile at (worldX+offsetX, worldY+offsetY)
/// equals expectedID.
class QueryTileNode : public Node {
public:
int32_t offsetX { 0 };
int32_t offsetY { 0 };
int32_t expectedID { 0 };
QueryTileNode() = default;
QueryTileNode(int32_t ox, int32_t oy, int32_t id)
: offsetX(ox), offsetY(oy), expectedID(id) {}
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryTile"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
return Value::MakeBool(
ctx.GetPrevTile(ctx.worldX + offsetX, ctx.worldY + offsetY) == expectedID);
}
};
/// Counts tiles matching tileID within the rectangle
/// [worldX+minX .. worldX+maxX] × [worldY+minY .. worldY+maxY] (inclusive).
class QueryRangeNode : public Node {
public:
int32_t minX { 0 };
int32_t minY { 1 };
int32_t maxX { 0 };
int32_t maxY { 4 };
int32_t tileID { 0 };
QueryRangeNode() = default;
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::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 {
int32_t count = 0;
for (int32_t dy = minY; dy <= maxY; ++dy)
for (int32_t dx = minX; dx <= maxX; ++dx)
if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID)
++count;
return Value::MakeFloat(static_cast<float>(count));
}
};
/// Returns the Chebyshev distance (max(|dx|,|dy|)) to the nearest tile of
/// tileID within maxDistance tiles. Returns maxDistance + 1 if none found.
/// The current cell (distance 0) is never considered a match.
class QueryDistanceNode : public Node {
public:
int32_t tileID { 0 };
int32_t maxDistance { 4 };
QueryDistanceNode() = default;
QueryDistanceNode(int32_t id, int32_t maxDist)
: tileID(id), maxDistance(maxDist) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "QueryDistance"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
int32_t best = maxDistance + 1;
for (int32_t dy = -maxDistance; dy <= maxDistance; ++dy) {
for (int32_t dx = -maxDistance; dx <= maxDistance; ++dx) {
int32_t d = (std::abs(dx) > std::abs(dy)) ? std::abs(dx) : std::abs(dy);
if (d == 0 || d >= best) continue;
if (ctx.GetPrevTile(ctx.worldX + dx, ctx.worldY + dy) == tileID)
best = d;
}
}
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);
}
};
// ─────────────────────────────── Source / constant nodes ─────────────────────
/// Outputs a fixed floating-point constant (no inputs).
class ConstantNode : public Node {
public:
float value { 0.0f };
explicit ConstantNode(float v = 0.0f) : value(v) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "Constant"; }
Value Evaluate(const EvalContext&, const std::vector<Value>&) const override { return Value::MakeFloat(value); };
};
/// Outputs a tile identifier as an integer (no inputs).
class IDNode : public Node {
public:
int32_t tileID { 0 };
explicit IDNode(int32_t id = 0) : tileID(id) {}
Type GetOutputType() const override { return Type::Int; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "TileID"; }
Value Evaluate(const EvalContext&, const std::vector<Value>&) const override { return Value::MakeInt(tileID); };
};
/// Outputs the world-space X coordinate of the cell being evaluated.
class PositionXNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "PositionX"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override { return Value::MakeFloat(ctx.worldX); };
};
/// Outputs the world-space Y coordinate of the cell being evaluated.
class PositionYNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "PositionY"; }
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)
class AbsNode : 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 "Abs"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::abs(in[0].AsFloat()));
}
};
/// a (Float)
class NegateNode : 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 "Negate"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(-in[0].AsFloat());
}
};
// ─────────────────────────────── Min / Max / Clamp ───────────────────────────
/// min(a, b) (Float)
class MinNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Min"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(std::min(in[0].AsFloat(), in[1].AsFloat()));
}
};
/// max(a, b) (Float)
class MaxNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Max"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(std::max(in[0].AsFloat(), in[1].AsFloat()));
}
};
/// clamp(value, min, max) (Float)
class ClampNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float, Type::Float }; }
std::string GetName() const override { return "Clamp"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 3);
return Value::MakeFloat(std::max(in[1].AsFloat(), std::min(in[0].AsFloat(), in[2].AsFloat())));
}
};
// ─────────────────────────────── Map (range remap) ───────────────────────────
/// Remaps a value from [min0, max0] to [min1, max1].
///
/// inputs[0] value to remap (Float)
///
/// The four range endpoints are baked into the node at construction time.
/// When min0 == max0 the output is min1 (avoids divide-by-zero).
class MapNode : public Node {
public:
float min0 { 0.0f };
float max0 { 1.0f };
float min1 { 0.0f };
float max1 { 1.0f };
MapNode() = default;
MapNode(float mn0, float mx0, float mn1, float mx1)
: min0(mn0), max0(mx0), min1(mn1), max1(mx1) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Map"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
float range0 = max0 - min0;
if (range0 == 0.0f) return Value::MakeFloat(min1);
float t = (in[0].AsFloat() - min0) / range0;
return Value::MakeFloat(min1 + t * (max1 - min1));
}
};
// ─────────────────────────────── Int control flow ────────────────────────────
/// Selects between two integer inputs based on a boolean condition.
///
/// inputs[0] condition (Bool)
/// inputs[1] value if true (Int)
/// inputs[2] value if false (Int)
///
/// Unlike BranchNode, inputs are typed Int so the visual editor can flag
/// float connections at authoring time.
class IntBranchNode : public Node {
public:
Type GetOutputType() const override { return Type::Int; }
std::vector<Type> GetInputTypes() const override {
return { Type::Bool, Type::Int, Type::Int };
}
std::string GetName() const override { return "IntBranch"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 3);
return Value::MakeInt(in[0].AsBool() ? in[1].AsInt() : in[2].AsInt());
}
};
// ─────────────────────────────── 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:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float }; }
std::string GetName() const override { return "Sqrt"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(std::sqrt(std::max(0.0f, in[0].AsFloat())));
}
};
/// pow(a, b) (Float)
class PowNode : public Node {
public:
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return { Type::Float, Type::Float }; }
std::string GetName() const override { return "Pow"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeFloat(std::pow(in[0].AsFloat(), in[1].AsFloat()));
}
};
/// a * a (Float)
class SquareNode : 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 "Square"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
float a = in[0].AsFloat();
return Value::MakeFloat(a * a);
}
};
/// 1 a (Float)
class OneMinusNode : 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 "OneMinus"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 1);
return Value::MakeFloat(1.0f - in[0].AsFloat());
}
};
// ─────────────────────────────── Boolean logic (extended) ────────────────────
/// Outputs a XOR b (Bool)
class XorNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "Xor"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsBool() != in[1].AsBool());
}
};
/// Outputs !(a && b) (Bool)
class NandNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "Nand"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(!(in[0].AsBool() && in[1].AsBool()));
}
};
/// Outputs !(a || b) (Bool)
class NorNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "Nor"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(!(in[0].AsBool() || in[1].AsBool()));
}
};
/// Outputs !(a XOR b) — true when both inputs are equal (Bool)
class XnorNode : public Node {
public:
Type GetOutputType() const override { return Type::Bool; }
std::vector<Type> GetInputTypes() const override { return { Type::Bool, Type::Bool }; }
std::string GetName() const override { return "Xnor"; }
Value Evaluate(const EvalContext&, const std::vector<Value>& in) const override {
DEV_ASSERT(in.size() == 2);
return Value::MakeBool(in[0].AsBool() == in[1].AsBool());
}
};
// ─────────────────────────────── Noise nodes ─────────────────────────────────
//
// 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.
/// Perlin noise — output in [-1, 1] (Float)
class PerlinNoiseNode : public Node {
public:
float frequency { 0.01f };
explicit PerlinNoiseNode(float freq = 0.01f) : frequency(freq) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "PerlinNoise"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
FastNoiseLite fn;
fn.SetSeed(static_cast<int>(ctx.seed));
fn.SetNoiseType(FastNoiseLite::NoiseType_Perlin);
fn.SetFrequency(frequency);
return Value::MakeFloat(fn.GetNoise(static_cast<float>(ctx.worldX),
static_cast<float>(ctx.worldY)));
}
};
/// OpenSimplex2 noise — output in [-1, 1] (Float)
class SimplexNoiseNode : public Node {
public:
float frequency { 0.01f };
explicit SimplexNoiseNode(float freq = 0.01f) : frequency(freq) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "SimplexNoise"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
FastNoiseLite fn;
fn.SetSeed(static_cast<int>(ctx.seed));
fn.SetNoiseType(FastNoiseLite::NoiseType_OpenSimplex2);
fn.SetFrequency(frequency);
return Value::MakeFloat(fn.GetNoise(static_cast<float>(ctx.worldX),
static_cast<float>(ctx.worldY)));
}
};
/// 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::Bool; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "CellularNoise"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
// Map world position into lattice space.
float sx = ctx.worldX * frequency;
float sy = ctx.worldY * frequency;
int32_t cellX = static_cast<int32_t>(std::floor(sx));
int32_t cellY = static_cast<int32_t>(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<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);
}
};
/// Value noise — output in [-1, 1] (Float)
class ValueNoiseNode : public Node {
public:
float frequency { 0.01f };
explicit ValueNoiseNode(float freq = 0.01f) : frequency(freq) {}
Type GetOutputType() const override { return Type::Float; }
std::vector<Type> GetInputTypes() const override { return {}; }
std::string GetName() const override { return "ValueNoise"; }
Value Evaluate(const EvalContext& ctx, const std::vector<Value>&) const override {
FastNoiseLite fn;
fn.SetSeed(static_cast<int>(ctx.seed));
fn.SetNoiseType(FastNoiseLite::NoiseType_Value);
fn.SetFrequency(frequency);
return Value::MakeFloat(fn.GetNoise(static_cast<float>(ctx.worldX),
static_cast<float>(ctx.worldY)));
}
};
} // namespace WorldGraph

View File

@@ -0,0 +1,56 @@
#pragma once
#include "WorldGraph/WorldGraph.h"
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace WorldGraph {
/// Serializes and deserializes a Graph to/from JSON using nlohmann/json.
///
/// JSON format
/// ───────────
/// {
/// "nextId": <uint>,
/// "nodes": [
/// { "id": <uint>, "type": "<GetName()>", ...node-specific fields... },
/// ...
/// ],
/// "connections": [
/// { "from": <uint>, "to": <uint>, "slot": <int> },
/// ...
/// ]
/// }
///
/// Node-specific fields
/// ────────────────────
/// Constant : "value" (float)
/// TileID : "tileId" (int)
/// All other nodes have no extra fields.
class GraphSerializer {
public:
/// Serialise \p graph to a JSON object.
static nlohmann::json ToJson (const Graph& graph);
/// Reconstruct a Graph from a JSON object produced by ToJson().
/// Returns nullopt if the JSON is structurally invalid or contains an
/// unrecognised node type.
static std::optional<Graph> FromJson(const nlohmann::json& j);
/// Write the graph as pretty-printed JSON to \p path.
/// Returns false if the file could not be opened/written.
static bool Save(const Graph& graph, const std::string& path);
/// Read and reconstruct a Graph from \p path.
/// Returns nullopt if the file cannot be read or the JSON is invalid.
static std::optional<Graph> Load(const std::string& path);
private:
/// Construct a Node of the given type name, reading any config fields from \p j.
/// Returns nullptr for unrecognised type names.
static std::unique_ptr<Node> CreateNode(const std::string& type,
const nlohmann::json& j);
};
} // namespace WorldGraph

View File

@@ -0,0 +1,104 @@
#pragma once
#include <cstdint>
namespace WorldGraph {
/// Types that can flow through graph edges.
enum class Type : uint8_t {
Int, ///< Integer — used for tile IDs, positions, counts.
Float, ///< Floating-point — used for math and noise.
Bool, ///< Boolean — used for conditions / comparisons.
};
/// A tagged value flowing along a graph edge.
/// Provides coercion helpers so nodes can request any type they need.
struct Value {
Type type { Type::Float };
union { int32_t i; float f; bool b; } data {};
inline static Value MakeInt (int32_t v) noexcept { Value r; r.type = Type::Int; r.data.i = v; return r; }
inline static Value MakeFloat(float v) noexcept { Value r; r.type = Type::Float; r.data.f = v; return r; }
inline static Value MakeBool (bool v) noexcept { Value r; r.type = Type::Bool; r.data.b = v; return r; }
float AsFloat() const noexcept;
int32_t AsInt () const noexcept;
bool AsBool () const noexcept;
bool operator==(const Value& o) const noexcept;
bool operator!=(const Value& o) const noexcept { return !(*this == o); }
};
/// Per-cell context forwarded to every node during graph evaluation.
struct EvalContext {
int32_t worldX { 0 }; ///< World-space tile column.
int32_t worldY { 0 }; ///< World-space tile row.
uint64_t seed { 0 }; ///< World seed for deterministic noise.
// ── Previous-pass tile data (optional) ────────────────────────────────
// Set by the chunk generator before calling Evaluate().
// Row-major: prevTiles[ly * prevWidth + lx], lx = worldX - prevOriginX.
const int32_t* prevTiles { nullptr };
int32_t prevOriginX { 0 };
int32_t prevOriginY { 0 };
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;
inline bool HasPrevPass() const noexcept { return prevTiles != nullptr; }
};
inline float Value::AsFloat() const noexcept {
switch (type) {
case Type::Float: return data.f;
case Type::Int: return static_cast<float>(data.i);
case Type::Bool: return data.b ? 1.0f : 0.0f;
}
return 0.0f;
}
inline int32_t Value::AsInt() const noexcept {
switch (type) {
case Type::Int: return data.i;
case Type::Float: return static_cast<int32_t>(data.f);
case Type::Bool: return data.b ? 1 : 0;
}
return 0;
}
inline bool Value::AsBool() const noexcept {
switch (type) {
case Type::Bool: return data.b;
case Type::Int: return data.i != 0;
case Type::Float: return data.f != 0.0f;
}
return false;
}
inline int32_t EvalContext::GetPrevTile(int32_t x, int32_t y) const noexcept {
if (!prevTiles) return 0;
int32_t lx = x - prevOriginX;
int32_t ly = y - prevOriginY;
if (lx < 0 || lx >= prevWidth || ly < 0 || ly >= prevHeight) return 0;
return prevTiles[ly * prevWidth + lx];
}
inline bool Value::operator==(const Value& o) const noexcept {
if (type != o.type) return false;
switch (type) {
case Type::Int: return data.i == o.data.i;
case Type::Float: return data.f == o.data.f;
case Type::Bool: return data.b == o.data.b;
}
return false;
}
} // namespace WorldGraph

View File

@@ -0,0 +1,115 @@
#include "WorldGraph/WorldGraph.h"
namespace WorldGraph {
// ─────────────────────────────── Graph ────────────────────────────────────────
Graph::NodeID Graph::AddNode(std::unique_ptr<Node> node) {
NodeID id = nextID++;
nodes.emplace(id, std::move(node));
return id;
}
void Graph::RemoveNode(NodeID id) {
nodes.erase(id);
for (auto it = connections.begin(); it != connections.end(); ) {
if (it->first.toNode == id || it->second == id)
it = connections.erase(it);
else
++it;
}
}
Node* Graph::GetNode(NodeID id) {
auto it = nodes.find(id);
return it != nodes.end() ? it->second.get() : nullptr;
}
const Node* Graph::GetNode(NodeID id) const {
auto it = nodes.find(id);
return it != nodes.end() ? it->second.get() : nullptr;
}
bool Graph::Connect(NodeID fromNode, NodeID toNode, int inputSlot) {
if (!nodes.count(fromNode) || !nodes.count(toNode))
return false;
if (WouldCreateCycle(fromNode, toNode))
return false;
connections[{toNode, inputSlot}] = fromNode;
return true;
}
void Graph::Disconnect(NodeID toNode, int inputSlot) {
connections.erase({toNode, inputSlot});
}
std::optional<Graph::NodeID> Graph::GetInput(NodeID toNode, int inputSlot) const {
auto it = connections.find({toNode, inputSlot});
if (it != connections.end()) return it->second;
return std::nullopt;
}
void Graph::CollectDependencies(NodeID id, std::unordered_set<NodeID>& out) const {
if (!out.insert(id).second) return; // already visited
const Node* n = GetNode(id);
if (!n) return;
for (int s = 0; s < static_cast<int>(n->GetInputCount()); ++s) {
auto src = GetInput(id, s);
if (src) CollectDependencies(*src, out);
}
}
bool Graph::WouldCreateCycle(NodeID from, NodeID to) const {
// Adding edge (from → to) creates a cycle when 'to' is already an
// upstream dependency of 'from' (i.e., 'from' already depends on 'to').
std::unordered_set<NodeID> deps;
CollectDependencies(from, deps);
return deps.count(to) > 0;
}
Value Graph::Evaluate(NodeID outputNode, const EvalContext& ctx) const {
return EvaluateImpl(outputNode, ctx);
}
Value Graph::EvaluateImpl(NodeID id, const EvalContext& ctx) const {
const Node* node = GetNode(id);
if (!node) return Value::MakeFloat(0.0f);
const auto& inputTypes = node->GetInputTypes();
std::vector<Value> inputs;
inputs.reserve(inputTypes.size());
for (int slot = 0; slot < static_cast<int>(inputTypes.size()); ++slot) {
auto src = GetInput(id, slot);
if (src) {
inputs.push_back(EvaluateImpl(*src, ctx));
} else {
// Unconnected inputs default to the zero value for their declared type.
switch (inputTypes[slot]) {
case Type::Int: inputs.push_back(Value::MakeInt(0)); break;
case Type::Float: inputs.push_back(Value::MakeFloat(0.0f)); break;
case Type::Bool: inputs.push_back(Value::MakeBool(false)); break;
}
}
}
return node->Evaluate(ctx, inputs);
}
bool Graph::IsValid(NodeID outputNode) const {
const Node* node = GetNode(outputNode);
if (!node) return false;
for (int s = 0; s < static_cast<int>(node->GetInputCount()); ++s) {
auto src = GetInput(outputNode, s);
if (!src || !IsValid(*src)) return false;
}
return true;
}
std::vector<Graph::NodeID> Graph::GetDependencies(NodeID outputNode) const {
std::unordered_set<NodeID> visited;
CollectDependencies(outputNode, visited);
return { visited.begin(), visited.end() };
}
} // namespace WorldGraph

View File

@@ -0,0 +1,186 @@
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphNode.h" // for dynamic_cast to query node types
#include <algorithm>
#include <cstdlib> // std::abs (integer)
namespace WorldGraph {
// ─────────────────────────────── TileGrid ────────────────────────────────────
bool TileGrid::Contains(int32_t worldX, int32_t worldY) const noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
return lx >= 0 && lx < width && ly >= 0 && ly < height;
}
int32_t TileGrid::Get(int32_t worldX, int32_t worldY) const noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
if (lx < 0 || lx >= width || ly < 0 || ly >= height) return 0;
return data[static_cast<size_t>(ly) * width + lx];
}
void TileGrid::Set(int32_t worldX, int32_t worldY, int32_t id) noexcept {
int32_t lx = worldX - originX;
int32_t ly = worldY - originY;
if (lx < 0 || lx >= width || ly < 0 || ly >= height) return;
data[static_cast<size_t>(ly) * width + lx] = id;
}
EvalContext TileGrid::MakeEvalContext(int32_t wx, int32_t wy, uint64_t seed) const noexcept {
EvalContext ctx;
ctx.worldX = wx;
ctx.worldY = wy;
ctx.seed = seed;
ctx.prevTiles = data.empty() ? nullptr : data.data();
ctx.prevOriginX = originX;
ctx.prevOriginY = originY;
ctx.prevWidth = width;
ctx.prevHeight = height;
return ctx;
}
// ─────────────────────────────── PaddingBounds ───────────────────────────────
void PaddingBounds::Include(int32_t dx, int32_t dy) noexcept {
if (dx < 0) negX = std::max(negX, -dx);
if (dx > 0) posX = std::max(posX, dx);
if (dy < 0) negY = std::max(negY, -dy);
if (dy > 0) posY = std::max(posY, dy);
}
void PaddingBounds::Include(const PaddingBounds& o) noexcept {
negX = std::max(negX, o.negX);
posX = std::max(posX, o.posX);
negY = std::max(negY, o.negY);
posY = std::max(posY, o.posY);
}
// ─────────────────────────────── ComputeRequiredPadding ──────────────────────
PaddingBounds ComputeRequiredPadding(const Graph& graph, Graph::NodeID outputNode) {
PaddingBounds bounds;
for (Graph::NodeID id : graph.GetDependencies(outputNode)) {
const Node* n = graph.GetNode(id);
if (!n) continue;
if (const auto* qt = dynamic_cast<const QueryTileNode*>(n)) {
bounds.Include(qt->offsetX, qt->offsetY);
}
else if (const auto* qr = dynamic_cast<const QueryRangeNode*>(n)) {
bounds.Include(qr->minX, qr->minY);
bounds.Include(qr->maxX, qr->maxY);
}
else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(n)) {
int32_t d = qd->maxDistance;
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;
}
// ─────────────────────────────── GenerateRegion ──────────────────────────────
TileGrid GenerateRegion(
const Graph& graph,
Graph::NodeID outputNode,
int32_t originX, int32_t originY,
int32_t width, int32_t height,
const TileGrid* prevPassData,
uint64_t seed)
{
TileGrid grid(originX, originY, width, height);
for (int32_t ly = 0; ly < height; ++ly) {
for (int32_t lx = 0; lx < width; ++lx) {
int32_t wx = originX + lx;
int32_t wy = originY + ly;
EvalContext ctx;
if (prevPassData) {
ctx = prevPassData->MakeEvalContext(wx, wy, seed);
} else {
ctx.worldX = wx;
ctx.worldY = wy;
ctx.seed = seed;
}
int32_t tileID = graph.Evaluate(outputNode, ctx).AsInt();
if (tileID != 0) {
grid.Set(wx, wy, tileID);
} else if (prevPassData) {
// 0 = "no change": carry forward the previous pass value.
grid.Set(wx, wy, prevPassData->Get(wx, wy));
}
// else: tileID == 0, no prev data → tile stays 0 (already initialised).
}
}
return grid;
}
// ─────────────────────────────── GenerateChunk ───────────────────────────────
TileGrid GenerateChunk(
const std::vector<GenerationPass>& passes,
int32_t chunkOriginX, int32_t chunkOriginY,
int32_t chunkWidth, int32_t chunkHeight,
uint64_t seed)
{
if (passes.empty())
return TileGrid(chunkOriginX, chunkOriginY, chunkWidth, chunkHeight);
const size_t N = passes.size();
// Compute the output region for each pass (working backwards from the final chunk).
//
// regions[N-1] = final chunk (exact).
// regions[i-1] = regions[i] expanded by the padding that passes[i] needs.
//
// Pass i-1 must generate a region large enough for pass i to query into.
struct Region { int32_t ox, oy, w, h; };
std::vector<Region> regions(N);
regions[N - 1] = { chunkOriginX, chunkOriginY, chunkWidth, chunkHeight };
for (size_t i = N - 1; i > 0; --i) {
const Region& r = regions[i];
PaddingBounds p = ComputeRequiredPadding(passes[i].graph, passes[i].outputNode);
regions[i - 1] = {
r.ox - p.negX,
r.oy - p.negY,
r.w + p.TotalX(),
r.h + p.TotalY()
};
}
// Execute passes in forward order, feeding each output into the next.
TileGrid prev;
bool hasPrev = false;
for (size_t i = 0; i < N; ++i) {
const Region& r = regions[i];
TileGrid next = GenerateRegion(
passes[i].graph, passes[i].outputNode,
r.ox, r.oy, r.w, r.h,
hasPrev ? &prev : nullptr,
seed);
prev = std::move(next);
hasPrev = true;
}
// Trim back to exactly the final chunk region if there is only one pass
// (the last pass already generates the exact region, so no trimming is
// needed in the multi-pass case).
return prev;
}
} // namespace WorldGraph

View File

@@ -0,0 +1,242 @@
#include "WorldGraph/WorldGraphSerializer.h"
#include <algorithm>
#include <fstream>
#include <vector>
namespace WorldGraph {
// ─────────────────────────────── Node factory ────────────────────────────────
std::unique_ptr<Node> GraphSerializer::CreateNode(const std::string& type,
const nlohmann::json& j)
{
if (type == "Add") return std::make_unique<AddNode>();
if (type == "Subtract") return std::make_unique<SubtractNode>();
if (type == "Multiply") return std::make_unique<MultiplyNode>();
if (type == "Divide") return std::make_unique<DivideNode>();
if (type == "Less") return std::make_unique<LessNode>();
if (type == "Greater") return std::make_unique<GreaterNode>();
if (type == "LessEqual") return std::make_unique<LessEqualNode>();
if (type == "GreaterEqual") return std::make_unique<GreaterEqualNode>();
if (type == "Equal") return std::make_unique<EqualNode>();
if (type == "And") return std::make_unique<AndNode>();
if (type == "Or") return std::make_unique<OrNode>();
if (type == "Not") return std::make_unique<NotNode>();
if (type == "Xor") return std::make_unique<XorNode>();
if (type == "Nand") return std::make_unique<NandNode>();
if (type == "Nor") return std::make_unique<NorNode>();
if (type == "Xnor") return std::make_unique<XnorNode>();
if (type == "Branch") return std::make_unique<BranchNode>();
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>();
if (type == "OneMinus") return std::make_unique<OneMinusNode>();
if (type == "Abs") return std::make_unique<AbsNode>();
if (type == "Negate") return std::make_unique<NegateNode>();
if (type == "Min") return std::make_unique<MinNode>();
if (type == "Max") return std::make_unique<MaxNode>();
if (type == "Clamp") return std::make_unique<ClampNode>();
if (type == "Map") {
return std::make_unique<MapNode>(
j.value("min0", 0.0f),
j.value("max0", 1.0f),
j.value("min1", 0.0f),
j.value("max1", 1.0f));
}
// 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));
if (type == "ValueNoise") return std::make_unique<ValueNoiseNode>(j.value("frequency", 0.01f));
// Nodes with config data
if (type == "Constant") {
return std::make_unique<ConstantNode>(j.value("value", 0.0f));
}
if (type == "TileID") {
return std::make_unique<IDNode>(j.value("tileId", 0));
}
if (type == "QueryTile") {
return std::make_unique<QueryTileNode>(
j.value("offsetX", 0),
j.value("offsetY", 0),
j.value("expectedID", 0));
}
if (type == "QueryRange") {
return std::make_unique<QueryRangeNode>(
j.value("minX", 0),
j.value("minY", 1),
j.value("maxX", 0),
j.value("maxY", 4),
j.value("tileId", 0));
}
if (type == "QueryDistance") {
return std::make_unique<QueryDistanceNode>(
j.value("tileId", 0),
j.value("maxDistance", 4));
}
if (type == "QueryLiquid") {
return std::make_unique<LiquidNode>(
j.value("maxWidth", 8),
j.value("maxDepth", 4));
}
return nullptr; // unrecognised type
}
// ─────────────────────────────── ToJson ──────────────────────────────────────
nlohmann::json GraphSerializer::ToJson(const Graph& g)
{
nlohmann::json j;
j["nextId"] = g.nextID;
// Nodes — sorted by ID for deterministic output.
std::vector<Graph::NodeID> ids;
ids.reserve(g.nodes.size());
for (const auto& [id, _] : g.nodes)
ids.push_back(id);
std::sort(ids.begin(), ids.end());
auto& jNodes = j["nodes"] = nlohmann::json::array();
for (Graph::NodeID id : ids) {
const Node* node = g.GetNode(id);
nlohmann::json jNode;
jNode["id"] = id;
jNode["type"] = node->GetName();
// Node-specific config fields
if (const auto* cn = dynamic_cast<const ConstantNode*>(node)) {
jNode["value"] = cn->value;
} else if (const auto* idn = dynamic_cast<const IDNode*>(node)) {
jNode["tileId"] = idn->tileID;
} else if (const auto* qt = dynamic_cast<const QueryTileNode*>(node)) {
jNode["offsetX"] = qt->offsetX;
jNode["offsetY"] = qt->offsetY;
jNode["expectedID"] = qt->expectedID;
} else if (const auto* qr = dynamic_cast<const QueryRangeNode*>(node)) {
jNode["minX"] = qr->minX;
jNode["minY"] = qr->minY;
jNode["maxX"] = qr->maxX;
jNode["maxY"] = qr->maxY;
jNode["tileId"] = qr->tileID;
} else if (const auto* qd = dynamic_cast<const QueryDistanceNode*>(node)) {
jNode["tileId"] = qd->tileID;
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)) {
jNode["min0"] = mn->min0;
jNode["max0"] = mn->max0;
jNode["min1"] = mn->min1;
jNode["max1"] = mn->max1;
} else if (const auto* pn = dynamic_cast<const PerlinNoiseNode*>(node)) {
jNode["frequency"] = pn->frequency;
} else if (const auto* sn = dynamic_cast<const SimplexNoiseNode*>(node)) {
jNode["frequency"] = sn->frequency;
} else if (const auto* cn = dynamic_cast<const CellularNoiseNode*>(node)) {
jNode["frequency"] = cn->frequency;
} else if (const auto* vn = dynamic_cast<const ValueNoiseNode*>(node)) {
jNode["frequency"] = vn->frequency;
}
jNodes.push_back(std::move(jNode));
}
// Connections — sorted by (toNode, slot) for deterministic output.
struct ConnEntry { Graph::NodeID from, to; int slot; };
std::vector<ConnEntry> conns;
conns.reserve(g.connections.size());
for (const auto& [key, from] : g.connections)
conns.push_back({ from, key.toNode, key.slot });
std::sort(conns.begin(), conns.end(), [](const ConnEntry& a, const ConnEntry& b) {
return a.to != b.to ? a.to < b.to : a.slot < b.slot;
});
auto& jConns = j["connections"] = nlohmann::json::array();
for (const auto& c : conns) {
nlohmann::json jConn;
jConn["from"] = c.from;
jConn["to"] = c.to;
jConn["slot"] = c.slot;
jConns.push_back(std::move(jConn));
}
return j;
}
// ─────────────────────────────── FromJson ────────────────────────────────────
std::optional<Graph> GraphSerializer::FromJson(const nlohmann::json& j)
{
if (!j.contains("nextId") || !j.contains("nodes") || !j.contains("connections"))
return std::nullopt;
if (!j["nodes"].is_array() || !j["connections"].is_array())
return std::nullopt;
Graph g;
g.nextID = j["nextId"].get<Graph::NodeID>();
// Restore nodes directly into the map to preserve original IDs.
for (const auto& jNode : j["nodes"]) {
if (!jNode.contains("id") || !jNode.contains("type"))
return std::nullopt;
auto id = jNode["id"].get<Graph::NodeID>();
auto type = jNode["type"].get<std::string>();
auto node = CreateNode(type, jNode);
if (!node) return std::nullopt; // unrecognised node type
g.nodes.emplace(id, std::move(node));
}
// Restore connections directly — skip the cycle check since we trust
// that the saved graph was already valid when it was serialised.
for (const auto& jConn : j["connections"]) {
if (!jConn.contains("from") || !jConn.contains("to") || !jConn.contains("slot"))
return std::nullopt;
auto from = jConn["from"].get<Graph::NodeID>();
auto to = jConn["to"].get<Graph::NodeID>();
auto slot = jConn["slot"].get<int>();
g.connections[{to, slot}] = from;
}
return g;
}
// ─────────────────────────────── File I/O ────────────────────────────────────
bool GraphSerializer::Save(const Graph& graph, const std::string& path)
{
std::ofstream f(path);
if (!f) return false;
f << ToJson(graph).dump(2);
return f.good();
}
std::optional<Graph> GraphSerializer::Load(const std::string& path)
{
std::ifstream f(path);
if (!f) return std::nullopt;
try {
return FromJson(nlohmann::json::parse(f));
} catch (...) {
return std::nullopt;
}
}
} // namespace WorldGraph

View File

@@ -0,0 +1,468 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraph.h"
#include "WorldGraph/WorldGraphSerializer.h"
using namespace WorldGraph;
TEST_SUITE("WorldGraph::Nodes") {
EvalContext ctx {}; // default zero context
// ── Math ─────────────────────────────────────────────────────────────────
TEST_CASE("AddNode: float + float") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(7.0f));
}
TEST_CASE("AddNode: coerces Int inputs") {
AddNode n;
CHECK(n.Evaluate(ctx, { Value::MakeInt(2), Value::MakeInt(3) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("SubtractNode: positive result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(6.0f));
}
TEST_CASE("SubtractNode: negative result") {
SubtractNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(2.0f), Value::MakeFloat(5.0f) }).AsFloat()
== doctest::Approx(-3.0f));
}
TEST_CASE("MultiplyNode") {
MultiplyNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsFloat()
== doctest::Approx(12.0f));
}
TEST_CASE("DivideNode: normal division") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(10.0f), Value::MakeFloat(2.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("DivideNode: division by zero returns 0") {
DivideNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(0.0f) }).AsFloat()
== doctest::Approx(0.0f));
}
// ── Comparisons ──────────────────────────────────────────────────────────
TEST_CASE("LessNode") {
LessNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterNode") {
GreaterNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("LessEqualNode: includes equality") {
LessEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("GreaterEqualNode: includes equality") {
GreaterEqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f), Value::MakeFloat(0.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f), Value::MakeFloat(0.0f) }).AsBool() == false);
}
TEST_CASE("EqualNode") {
EqualNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(3.0f) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(4.0f) }).AsBool() == false);
}
// ── Boolean logic ─────────────────────────────────────────────────────────
TEST_CASE("AndNode") {
AndNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
}
TEST_CASE("OrNode") {
OrNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
}
TEST_CASE("NotNode") {
NotNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(true) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false) }).AsBool() == true);
}
// ── Control flow ──────────────────────────────────────────────────────────
TEST_CASE("BranchNode: selects true branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(10.0f));
}
TEST_CASE("BranchNode: selects false branch") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(false),
Value::MakeFloat(10.0f),
Value::MakeFloat(20.0f) });
CHECK(r.AsFloat() == doctest::Approx(20.0f));
}
TEST_CASE("BranchNode: preserves Int type (tile IDs pass through correctly)") {
BranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeInt(7),
Value::MakeInt(0) });
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 7);
}
// ── Source / constant ─────────────────────────────────────────────────────
TEST_CASE("ConstantNode: returns configured float") {
ConstantNode n(3.5f);
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(3.5f));
}
TEST_CASE("ConstantNode: default is 0") {
ConstantNode n;
CHECK(n.Evaluate(ctx, {}).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("IDNode: returns tile ID as Int") {
IDNode n(42);
auto r = n.Evaluate(ctx, {});
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 42);
}
TEST_CASE("PositionXNode: reads worldX from context") {
EvalContext c; c.worldX = 7;
PositionXNode n;
CHECK(n.Evaluate(c, {}).AsInt() == 7);
}
TEST_CASE("PositionYNode: reads worldY from context") {
EvalContext c; c.worldY = -3;
PositionYNode n;
CHECK(n.Evaluate(c, {}).AsInt() == -3);
}
// ── Abs / Negate ──────────────────────────────────────────────────────────
TEST_CASE("AbsNode: positive input unchanged") {
AbsNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f) }).AsFloat() == doctest::Approx(3.0f));
}
TEST_CASE("AbsNode: negative input becomes positive") {
AbsNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-7.5f) }).AsFloat() == doctest::Approx(7.5f));
}
TEST_CASE("AbsNode: zero") {
AbsNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("NegateNode: positive becomes negative") {
NegateNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(4.0f) }).AsFloat() == doctest::Approx(-4.0f));
}
TEST_CASE("NegateNode: negative becomes positive") {
NegateNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-2.5f) }).AsFloat() == doctest::Approx(2.5f));
}
TEST_CASE("NegateNode: zero") {
NegateNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(0.0f));
}
// ── Min / Max / Clamp ─────────────────────────────────────────────────────
TEST_CASE("MinNode: returns smaller value") {
MinNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(7.0f) }).AsFloat()
== doctest::Approx(3.0f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat(7.0f), Value::MakeFloat(3.0f) }).AsFloat()
== doctest::Approx(3.0f));
}
TEST_CASE("MinNode: equal inputs") {
MinNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(5.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("MaxNode: returns larger value") {
MaxNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(3.0f), Value::MakeFloat(7.0f) }).AsFloat()
== doctest::Approx(7.0f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat(7.0f), Value::MakeFloat(3.0f) }).AsFloat()
== doctest::Approx(7.0f));
}
TEST_CASE("MaxNode: equal inputs") {
MaxNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(5.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("ClampNode: value within range passes through") {
ClampNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f), Value::MakeFloat(0.0f), Value::MakeFloat(10.0f) }).AsFloat()
== doctest::Approx(5.0f));
}
TEST_CASE("ClampNode: value below min clamped to min") {
ClampNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-5.0f), Value::MakeFloat(0.0f), Value::MakeFloat(10.0f) }).AsFloat()
== doctest::Approx(0.0f));
}
TEST_CASE("ClampNode: value above max clamped to max") {
ClampNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(15.0f), Value::MakeFloat(0.0f), Value::MakeFloat(10.0f) }).AsFloat()
== doctest::Approx(10.0f));
}
// ── Map (range remap) ─────────────────────────────────────────────────────
TEST_CASE("MapNode: maps midpoint correctly") {
MapNode n(0.0f, 1.0f, 0.0f, 100.0f);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.5f) }).AsFloat() == doctest::Approx(50.0f));
}
TEST_CASE("MapNode: maps min boundary") {
MapNode n(0.0f, 1.0f, 10.0f, 20.0f);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(10.0f));
}
TEST_CASE("MapNode: maps max boundary") {
MapNode n(0.0f, 1.0f, 10.0f, 20.0f);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f) }).AsFloat() == doctest::Approx(20.0f));
}
TEST_CASE("MapNode: maps noise range [-1, 1] to [0, 1]") {
MapNode n(-1.0f, 1.0f, 0.0f, 1.0f);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-1.0f) }).AsFloat() == doctest::Approx(0.0f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat( 0.0f) }).AsFloat() == doctest::Approx(0.5f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat( 1.0f) }).AsFloat() == doctest::Approx(1.0f));
}
TEST_CASE("MapNode: inverted output range") {
MapNode n(0.0f, 1.0f, 1.0f, 0.0f);
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(1.0f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f) }).AsFloat() == doctest::Approx(0.0f));
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.25f) }).AsFloat() == doctest::Approx(0.75f));
}
TEST_CASE("MapNode: degenerate input range returns min1") {
MapNode n(5.0f, 5.0f, 99.0f, 100.0f); // min0 == max0
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f) }).AsFloat() == doctest::Approx(99.0f));
}
// ── Int control flow ──────────────────────────────────────────────────────
TEST_CASE("IntBranchNode: selects true branch") {
IntBranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(true),
Value::MakeInt(7),
Value::MakeInt(99) });
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 7);
}
TEST_CASE("IntBranchNode: selects false branch") {
IntBranchNode n;
auto r = n.Evaluate(ctx, { Value::MakeBool(false),
Value::MakeInt(7),
Value::MakeInt(99) });
CHECK(r.type == Type::Int);
CHECK(r.AsInt() == 99);
}
TEST_CASE("IntBranchNode: output type is Int") {
IntBranchNode n;
CHECK(n.GetOutputType() == Type::Int);
CHECK(n.GetInputTypes() == std::vector<Type>{ Type::Bool, Type::Int, Type::Int });
}
// ── Extended math ─────────────────────────────────────────────────────────
TEST_CASE("SqrtNode: normal value") {
SqrtNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(9.0f) }).AsFloat() == doctest::Approx(3.0f));
}
TEST_CASE("SqrtNode: zero") {
SqrtNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("SqrtNode: negative input clamped to zero") {
SqrtNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-4.0f) }).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("PowNode: a^b") {
PowNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(2.0f), Value::MakeFloat(10.0f) }).AsFloat()
== doctest::Approx(1024.0f));
}
TEST_CASE("PowNode: anything to the power 0 is 1") {
PowNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(99.0f), Value::MakeFloat(0.0f) }).AsFloat()
== doctest::Approx(1.0f));
}
TEST_CASE("SquareNode: positive") {
SquareNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(5.0f) }).AsFloat() == doctest::Approx(25.0f));
}
TEST_CASE("SquareNode: negative input gives positive result") {
SquareNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(-3.0f) }).AsFloat() == doctest::Approx(9.0f));
}
TEST_CASE("OneMinusNode: 1 - 0.25 == 0.75") {
OneMinusNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.25f) }).AsFloat() == doctest::Approx(0.75f));
}
TEST_CASE("OneMinusNode: 1 - 0 == 1") {
OneMinusNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(0.0f) }).AsFloat() == doctest::Approx(1.0f));
}
TEST_CASE("OneMinusNode: 1 - 1 == 0") {
OneMinusNode n;
CHECK(n.Evaluate(ctx, { Value::MakeFloat(1.0f) }).AsFloat() == doctest::Approx(0.0f));
}
// ── Extended boolean logic ────────────────────────────────────────────────
TEST_CASE("XorNode: truth table") {
XorNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(true) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == false);
}
TEST_CASE("NandNode: truth table") {
NandNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(true) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == false);
}
TEST_CASE("NorNode: truth table") {
NorNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(true) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == false);
}
TEST_CASE("XnorNode: truth table") {
XnorNode n;
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(false) }).AsBool() == true);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(false) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(false), Value::MakeBool(true) }).AsBool() == false);
CHECK(n.Evaluate(ctx, { Value::MakeBool(true), Value::MakeBool(true) }).AsBool() == true);
}
// ── Noise nodes ───────────────────────────────────────────────────────────
TEST_CASE("PerlinNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 10; c.worldY = 20; c.seed = 42;
PerlinNoiseNode n(0.1f);
float v = n.Evaluate(c, {}).AsFloat();
CHECK(v >= -1.0f);
CHECK(v <= 1.0f);
}
TEST_CASE("PerlinNoiseNode: deterministic for same context") {
EvalContext c; c.worldX = 5; c.worldY = -3; c.seed = 123;
PerlinNoiseNode n(0.05f);
float v1 = n.Evaluate(c, {}).AsFloat();
float v2 = n.Evaluate(c, {}).AsFloat();
CHECK(v1 == doctest::Approx(v2));
}
TEST_CASE("SimplexNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 7; c.worldY = 13; c.seed = 1;
SimplexNoiseNode n(0.1f);
float v = n.Evaluate(c, {}).AsFloat();
CHECK(v >= -1.0f);
CHECK(v <= 1.0f);
}
TEST_CASE("SimplexNoiseNode: deterministic for same context") {
EvalContext c; c.worldX = 3; c.worldY = 8; c.seed = 77;
SimplexNoiseNode n(0.05f);
CHECK(n.Evaluate(c, {}).AsFloat() == doctest::Approx(n.Evaluate(c, {}).AsFloat()));
}
TEST_CASE("CellularNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 2; c.worldY = 9; c.seed = 500;
CellularNoiseNode n(0.1f);
float v = n.Evaluate(c, {}).AsFloat();
CHECK(v >= -1.0f);
CHECK(v <= 1.0f);
}
TEST_CASE("CellularNoiseNode: deterministic for same context") {
EvalContext c; c.worldX = 15; c.worldY = -7; c.seed = 256;
CellularNoiseNode n(0.08f);
CHECK(n.Evaluate(c, {}).AsFloat() == doctest::Approx(n.Evaluate(c, {}).AsFloat()));
}
TEST_CASE("ValueNoiseNode: output is float in [-1, 1]") {
EvalContext c; c.worldX = 4; c.worldY = 6; c.seed = 11;
ValueNoiseNode n(0.1f);
float v = n.Evaluate(c, {}).AsFloat();
CHECK(v >= -1.0f);
CHECK(v <= 1.0f);
}
TEST_CASE("ValueNoiseNode: deterministic for same context") {
EvalContext c; c.worldX = -2; c.worldY = 3; c.seed = 404;
ValueNoiseNode n(0.05f);
CHECK(n.Evaluate(c, {}).AsFloat() == doctest::Approx(n.Evaluate(c, {}).AsFloat()));
}
TEST_CASE("noise nodes: frequency affects output") {
EvalContext c; c.worldX = 100; c.worldY = 100; c.seed = 1;
PerlinNoiseNode lowFreq(0.001f);
PerlinNoiseNode highFreq(0.5f);
// Different frequencies must produce different values at the same position
CHECK(lowFreq.Evaluate(c, {}).AsFloat() != doctest::Approx(highFreq.Evaluate(c, {}).AsFloat()));
}
}

View File

@@ -0,0 +1,253 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraph.h"
#include "WorldGraph/WorldGraphSerializer.h"
#include <fstream>
using namespace WorldGraph;
TEST_SUITE("WorldGraph::Serialization") {
// Helper: build the flat-world graph used in several tests.
// Branch(Less(PositionY, Constant(0)), IDNode(STONE=1), IDNode(AIR=0))
static Graph MakeFlatWorldGraph(Graph::NodeID& outBranch) {
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(1));
auto air = g.AddNode(std::make_unique<IDNode>(0));
outBranch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(zero, less, 1));
REQUIRE(g.Connect(less, outBranch, 0));
REQUIRE(g.Connect(stone, outBranch, 1));
REQUIRE(g.Connect(air, outBranch, 2));
return g;
}
// ── ToJson / FromJson ─────────────────────────────────────────────────────
TEST_CASE("ToJson: empty graph produces valid structure") {
Graph g;
auto j = GraphSerializer::ToJson(g);
CHECK(j.contains("nextId"));
CHECK(j["nodes"].is_array());
CHECK(j["nodes"].empty());
CHECK(j["connections"].is_array());
CHECK(j["connections"].empty());
}
TEST_CASE("ToJson: node count and type names are correct") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
CHECK(j["nodes"].size() == 6);
CHECK(j["connections"].size() == 5);
// Collect type names
std::vector<std::string> types;
for (const auto& n : j["nodes"])
types.push_back(n["type"].get<std::string>());
CHECK(std::count(types.begin(), types.end(), "PositionY") == 1);
CHECK(std::count(types.begin(), types.end(), "Constant") == 1);
CHECK(std::count(types.begin(), types.end(), "Less") == 1);
CHECK(std::count(types.begin(), types.end(), "TileID") == 2);
CHECK(std::count(types.begin(), types.end(), "Branch") == 1);
}
TEST_CASE("ToJson: ConstantNode serialises its value") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(3.14f));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["value"].get<float>() == doctest::Approx(3.14f));
}
TEST_CASE("ToJson: IDNode serialises its tileId") {
Graph g;
g.AddNode(std::make_unique<IDNode>(42));
auto j = GraphSerializer::ToJson(g);
REQUIRE(!j["nodes"].empty());
CHECK(j["nodes"][0]["tileId"].get<int>() == 42);
}
TEST_CASE("ToJson: connections are sorted by (to, slot)") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
// Verify the array is ordered by ascending 'to', then 'slot'.
const auto& conns = j["connections"];
for (size_t i = 1; i < conns.size(); ++i) {
auto prevTo = conns[i-1]["to"].get<uint32_t>();
auto currTo = conns[i]["to"].get<uint32_t>();
auto prevSlot = conns[i-1]["slot"].get<int>();
auto currSlot = conns[i]["slot"].get<int>();
CHECK((currTo > prevTo || (currTo == prevTo && currSlot >= prevSlot)));
}
}
TEST_CASE("FromJson: round-trips node count and type") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == 6);
}
TEST_CASE("FromJson: round-trips ConstantNode value") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(2.71f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* cn = dynamic_cast<const ConstantNode*>(g2->GetNode(id));
REQUIRE(cn != nullptr);
CHECK(cn->value == doctest::Approx(2.71f));
}
TEST_CASE("FromJson: round-trips IDNode tileID") {
Graph g;
auto id = g.AddNode(std::make_unique<IDNode>(99));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
const auto* idn = dynamic_cast<const IDNode*>(g2->GetNode(id));
REQUIRE(idn != nullptr);
CHECK(idn->tileID == 99);
}
TEST_CASE("FromJson: round-trips connections") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetInput(add, 0) == c1);
CHECK(g2->GetInput(add, 1) == c2);
}
TEST_CASE("FromJson: preserves node IDs") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(7.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->GetNode(id) != nullptr);
}
TEST_CASE("FromJson: preserves nextId so new nodes get fresh IDs") {
Graph g;
auto id1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto id2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
// A new node must get an ID not already in use.
auto id3 = g2->AddNode(std::make_unique<ConstantNode>(3.0f));
CHECK(id3 != id1);
CHECK(id3 != id2);
}
TEST_CASE("FromJson: returns nullopt for missing required fields") {
CHECK(!GraphSerializer::FromJson({}).has_value());
CHECK(!GraphSerializer::FromJson(nlohmann::json::object()).has_value());
CHECK(!GraphSerializer::FromJson({ {"nextId", 1}, {"nodes", nullptr}, {"connections", nullptr} }).has_value());
}
TEST_CASE("FromJson: returns nullopt for unknown node type") {
nlohmann::json j;
j["nextId"] = 2;
j["nodes"] = {{ {"id", 1}, {"type", "NonExistentNode"} }};
j["connections"] = nlohmann::json::array();
CHECK(!GraphSerializer::FromJson(j).has_value());
}
// ── Evaluation after round-trip ───────────────────────────────────────────
TEST_CASE("Round-trip: flat world evaluates identically after FromJson") {
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
for (int y : { -5, -1, 0, 1, 5 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Round-trip: arithmetic graph evaluates identically after FromJson") {
// (3.0 + 4.0) - 2.0 = 5.0
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto c3 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
auto sub = g.AddNode(std::make_unique<SubtractNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
REQUIRE(g.Connect(add, sub, 0));
REQUIRE(g.Connect(c3, sub, 1));
auto j = GraphSerializer::ToJson(g);
auto g2 = GraphSerializer::FromJson(j);
REQUIRE(g2.has_value());
CHECK(g2->Evaluate(sub, {}).AsFloat() == doctest::Approx(5.0f));
}
// ── File Save / Load ──────────────────────────────────────────────────────
TEST_CASE("Save + Load: round-trip via file") {
const std::string path = "/tmp/test_worldgraph_save.json";
Graph::NodeID branch;
auto g = MakeFlatWorldGraph(branch);
REQUIRE(GraphSerializer::Save(g, path));
auto g2 = GraphSerializer::Load(path);
REQUIRE(g2.has_value());
CHECK(g2->NodeCount() == g.NodeCount());
for (int y : { -3, 0, 3 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g2->Evaluate(branch, ctx).AsInt() == g.Evaluate(branch, ctx).AsInt());
}
}
TEST_CASE("Save: returns false for an unwritable path") {
Graph g;
g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(!GraphSerializer::Save(g, "/no/such/directory/graph.json"));
}
TEST_CASE("Load: returns nullopt for a missing file") {
CHECK(!GraphSerializer::Load("/no/such/file.json").has_value());
}
TEST_CASE("Load: returns nullopt for malformed JSON") {
const std::string path = "/tmp/test_worldgraph_bad.json";
{
std::ofstream f(path);
f << "{ this is not valid json }";
}
CHECK(!GraphSerializer::Load(path).has_value());
}
}

View File

@@ -0,0 +1,452 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraph.h"
#include "WorldGraph/WorldGraphSerializer.h"
#include <fstream>
using namespace WorldGraph;
// ─────────────────────────────── Value ───────────────────────────────────────
TEST_SUITE("WorldGraph::Value") {
TEST_CASE("MakeFloat round-trips") {
auto v = Value::MakeFloat(3.14f);
CHECK(v.type == Type::Float);
CHECK(v.AsFloat() == doctest::Approx(3.14f));
}
TEST_CASE("MakeInt round-trips") {
auto v = Value::MakeInt(42);
CHECK(v.type == Type::Int);
CHECK(v.AsInt() == 42);
}
TEST_CASE("MakeBool round-trips") {
CHECK(Value::MakeBool(true).AsBool() == true);
CHECK(Value::MakeBool(false).AsBool() == false);
}
TEST_CASE("Int coerces to Float") {
CHECK(Value::MakeInt(5).AsFloat() == doctest::Approx(5.0f));
CHECK(Value::MakeInt(-3).AsFloat() == doctest::Approx(-3.0f));
}
TEST_CASE("Float coerces to Int (truncates toward zero)") {
CHECK(Value::MakeFloat(2.9f).AsInt() == 2);
CHECK(Value::MakeFloat(-1.9f).AsInt() == -1);
}
TEST_CASE("Bool coerces to Int") {
CHECK(Value::MakeBool(true).AsInt() == 1);
CHECK(Value::MakeBool(false).AsInt() == 0);
}
TEST_CASE("Non-zero numeric coerces to Bool true") {
CHECK(Value::MakeInt(7).AsBool() == true);
CHECK(Value::MakeInt(0).AsBool() == false);
CHECK(Value::MakeFloat(0.1f).AsBool() == true);
CHECK(Value::MakeFloat(0.0f).AsBool() == false);
}
TEST_CASE("Equality: same type and value") {
CHECK(Value::MakeInt(3) == Value::MakeInt(3));
CHECK(Value::MakeFloat(1.0f) == Value::MakeFloat(1.0f));
CHECK(Value::MakeBool(true) == Value::MakeBool(true));
}
TEST_CASE("Equality: different types are never equal") {
// Even if numerically equivalent, different tags → not equal.
CHECK_FALSE(Value::MakeInt(1) == Value::MakeFloat(1.0f));
CHECK_FALSE(Value::MakeInt(1) == Value::MakeBool(true));
CHECK_FALSE(Value::MakeFloat(0) == Value::MakeBool(false));
}
TEST_CASE("Inequality operator") {
CHECK(Value::MakeInt(1) != Value::MakeInt(2));
CHECK_FALSE(Value::MakeInt(5) != Value::MakeInt(5));
}
}
// ─────────────────────────────── Graph ───────────────────────────────────────
TEST_SUITE("WorldGraph::Graph") {
// ── Node management ───────────────────────────────────────────────────────
TEST_CASE("AddNode: assigns unique non-zero IDs") {
Graph g;
auto id1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto id2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
CHECK(id1 != Graph::INVALID_ID);
CHECK(id2 != Graph::INVALID_ID);
CHECK(id1 != id2);
CHECK(g.NodeCount() == 2);
}
TEST_CASE("GetNode: returns correct node") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(5.0f));
auto* n = g.GetNode(id);
REQUIRE(n != nullptr);
CHECK(n->GetName() == "Constant");
}
TEST_CASE("GetNode: returns nullptr for unknown ID") {
Graph g;
CHECK(g.GetNode(99) == nullptr);
}
TEST_CASE("RemoveNode: erases node and its connections") {
Graph g;
auto src = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(src, add, 0));
g.RemoveNode(src);
CHECK(g.GetNode(src) == nullptr);
CHECK(!g.GetInput(add, 0).has_value());
CHECK(g.NodeCount() == 1);
}
// ── Connection management ─────────────────────────────────────────────────
TEST_CASE("Connect: returns false for unknown node") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(!g.Connect(id, 999, 0));
CHECK(!g.Connect(999, id, 0));
}
TEST_CASE("Connect and GetInput: round-trip") {
Graph g;
auto from = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto to = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(from, to, 0));
auto inp = g.GetInput(to, 0);
REQUIRE(inp.has_value());
CHECK(*inp == from);
}
TEST_CASE("Connect: replaces an existing connection on the same slot") {
Graph g;
auto a = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto b = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto c = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(a, c, 0));
REQUIRE(g.Connect(b, c, 0)); // replaces a→c on slot 0
CHECK(*g.GetInput(c, 0) == b);
}
TEST_CASE("Disconnect: removes the connection") {
Graph g;
auto from = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto to = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(from, to, 0));
g.Disconnect(to, 0);
CHECK(!g.GetInput(to, 0).has_value());
}
TEST_CASE("Disconnect: is a no-op on unconnected slot") {
Graph g;
auto to = g.AddNode(std::make_unique<AddNode>());
g.Disconnect(to, 0); // must not crash
}
// ── Cycle detection ───────────────────────────────────────────────────────
TEST_CASE("Connect: rejects self-loop") {
Graph g;
auto id = g.AddNode(std::make_unique<AddNode>());
CHECK(!g.Connect(id, id, 0));
}
TEST_CASE("Connect: rejects direct cycle (A→B then B→A)") {
Graph g;
auto a = g.AddNode(std::make_unique<AddNode>());
auto b = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(a, b, 0)); // a → b (a feeds into b)
CHECK(!g.Connect(b, a, 0)); // b → a would create a cycle
}
TEST_CASE("Connect: rejects transitive cycle (A→B→C then C→A)") {
Graph g;
auto a = g.AddNode(std::make_unique<AddNode>());
auto b = g.AddNode(std::make_unique<AddNode>());
auto c = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(a, b, 0)); // a → b
REQUIRE(g.Connect(b, c, 0)); // b → c
CHECK(!g.Connect(c, a, 0)); // c → a would close the cycle
}
TEST_CASE("Connect: allows a shared dependency (diamond graph)") {
// shared → left → output
// ↘ right ↗
Graph g;
auto shared = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto left = g.AddNode(std::make_unique<AddNode>());
auto right = g.AddNode(std::make_unique<AddNode>());
auto output = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(shared, left, 0));
REQUIRE(g.Connect(shared, right, 0));
REQUIRE(g.Connect(left, output, 0));
REQUIRE(g.Connect(right, output, 1));
}
// ── Evaluation ────────────────────────────────────────────────────────────
TEST_CASE("Evaluate: standalone constant") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(42.0f));
CHECK(g.Evaluate(id, {}).AsFloat() == doctest::Approx(42.0f));
}
TEST_CASE("Evaluate: unknown node returns zero") {
Graph g;
CHECK(g.Evaluate(999, {}).AsFloat() == doctest::Approx(0.0f));
}
TEST_CASE("Evaluate: unconnected inputs default to zero") {
Graph g;
auto add = g.AddNode(std::make_unique<AddNode>());
CHECK(g.Evaluate(add, {}).AsFloat() == doctest::Approx(0.0f)); // 0+0
}
TEST_CASE("Evaluate: add two constants") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
CHECK(g.Evaluate(add, {}).AsFloat() == doctest::Approx(7.0f));
}
TEST_CASE("Evaluate: subtract two constants") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(10.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto sub = g.AddNode(std::make_unique<SubtractNode>());
REQUIRE(g.Connect(c1, sub, 0));
REQUIRE(g.Connect(c2, sub, 1));
CHECK(g.Evaluate(sub, {}).AsFloat() == doctest::Approx(7.0f));
}
TEST_CASE("Evaluate: chained additions ((1+2)+3) == 6") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto c3 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto add1 = g.AddNode(std::make_unique<AddNode>());
auto add2 = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add1, 0));
REQUIRE(g.Connect(c2, add1, 1));
REQUIRE(g.Connect(add1, add2, 0));
REQUIRE(g.Connect(c3, add2, 1));
CHECK(g.Evaluate(add2, {}).AsFloat() == doctest::Approx(6.0f));
}
TEST_CASE("Evaluate: Less comparison") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
REQUIRE(g.Connect(c1, less, 0));
REQUIRE(g.Connect(c2, less, 1));
CHECK(g.Evaluate(less, {}).AsBool() == true); // 1 < 2
}
TEST_CASE("Evaluate: Greater comparison") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(5.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto greater = g.AddNode(std::make_unique<GreaterNode>());
REQUIRE(g.Connect(c1, greater, 0));
REQUIRE(g.Connect(c2, greater, 1));
CHECK(g.Evaluate(greater, {}).AsBool() == true); // 5 > 3
}
TEST_CASE("Evaluate: PositionX/Y read from EvalContext") {
Graph g;
auto px = g.AddNode(std::make_unique<PositionXNode>());
auto py = g.AddNode(std::make_unique<PositionYNode>());
EvalContext ctx; ctx.worldX = 5; ctx.worldY = -3;
CHECK(g.Evaluate(px, ctx).AsInt() == 5);
CHECK(g.Evaluate(py, ctx).AsInt() == -3);
}
// ── IsValid ───────────────────────────────────────────────────────────────
TEST_CASE("IsValid: ConstantNode (no inputs) is valid") {
Graph g;
auto id = g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(g.IsValid(id));
}
TEST_CASE("IsValid: AddNode with no connections is invalid") {
Graph g;
auto add = g.AddNode(std::make_unique<AddNode>());
CHECK(!g.IsValid(add));
}
TEST_CASE("IsValid: AddNode fully connected is valid") {
Graph g;
auto c1 = g.AddNode(std::make_unique<ConstantNode>(1.0f));
auto c2 = g.AddNode(std::make_unique<ConstantNode>(2.0f));
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(c1, add, 0));
REQUIRE(g.Connect(c2, add, 1));
CHECK(g.IsValid(add));
}
TEST_CASE("IsValid: returns false for unknown node ID") {
Graph g;
CHECK(!g.IsValid(42));
}
// ─────────────────────── Integration: flat world ─────────────────────────
//
// Pass 1 — flat stone layer:
// output tile ID = (worldY < 0) ? STONE : AIR
//
// Graph:
// [PositionY] ──┐
// ├──> [Less] ──> [Branch] ──> output
// [Constant(0)] ┘ / \
// [ID(STONE)] [ID(AIR)]
//
TEST_CASE("Integration: flat world — below y=0 is stone, at or above is air") {
const int32_t AIR = 0;
const int32_t STONE = 1;
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
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(posY, less, 0)); // a = worldY
REQUIRE(g.Connect(zero, less, 1)); // b = 0
REQUIRE(g.Connect(less, branch, 0)); // condition
REQUIRE(g.Connect(stone, branch, 1)); // if true → stone
REQUIRE(g.Connect(air, branch, 2)); // if false → air
CHECK(g.IsValid(branch));
// Underground cells → stone
for (int y : { -1, -5, -100 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g.Evaluate(branch, ctx).AsInt() == STONE);
}
// Surface and above → air
for (int y : { 0, 1, 5, 100 }) {
INFO("y = " << y);
EvalContext ctx; ctx.worldY = y;
CHECK(g.Evaluate(branch, ctx).AsInt() == AIR);
}
}
// ─────────────────── Integration: conditional by X ───────────────────────
//
// Split the world vertically: x > 5 → RED, else → BLUE
//
TEST_CASE("Integration: tile depends on X position") {
const int32_t BLUE = 3;
const int32_t RED = 2;
Graph g;
auto posX = g.AddNode(std::make_unique<PositionXNode>());
auto five = g.AddNode(std::make_unique<ConstantNode>(5.0f));
auto greater = g.AddNode(std::make_unique<GreaterNode>());
auto branch = g.AddNode(std::make_unique<BranchNode>());
auto red = g.AddNode(std::make_unique<IDNode>(RED));
auto blue = g.AddNode(std::make_unique<IDNode>(BLUE));
REQUIRE(g.Connect(posX, greater, 0));
REQUIRE(g.Connect(five, greater, 1));
REQUIRE(g.Connect(greater, branch, 0));
REQUIRE(g.Connect(red, branch, 1));
REQUIRE(g.Connect(blue, branch, 2));
EvalContext ctx;
ctx.worldX = 6; CHECK(g.Evaluate(branch, ctx).AsInt() == RED);
ctx.worldX = 5; CHECK(g.Evaluate(branch, ctx).AsInt() == BLUE);
ctx.worldX = 3; CHECK(g.Evaluate(branch, ctx).AsInt() == BLUE);
}
// ─────────────────── Integration: arithmetic on position ─────────────────
//
// Evaluate (worldX + worldY) at a specific cell.
//
TEST_CASE("Integration: add X and Y positions") {
Graph g;
auto px = g.AddNode(std::make_unique<PositionXNode>());
auto py = g.AddNode(std::make_unique<PositionYNode>());
auto add = g.AddNode(std::make_unique<AddNode>());
REQUIRE(g.Connect(px, add, 0));
REQUIRE(g.Connect(py, add, 1));
EvalContext ctx; ctx.worldX = 3; ctx.worldY = 7;
CHECK(g.Evaluate(add, ctx).AsFloat() == doctest::Approx(10.0f));
}
// ─────────────────── Integration: nested branch ───────────────────────────
//
// Three-layer terrain:
// y < -10 → DEEP_STONE (ID=3)
// y < 0 → STONE (ID=1)
// otherwise → AIR (ID=0)
//
TEST_CASE("Integration: three-layer terrain with nested branch") {
const int32_t AIR = 0;
const int32_t STONE = 1;
const int32_t DEEP_STONE = 3;
Graph g;
// Sources
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto negTen = g.AddNode(std::make_unique<ConstantNode>(-10.0f));
auto zeroC = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto idAir = g.AddNode(std::make_unique<IDNode>(AIR));
auto idStone = g.AddNode(std::make_unique<IDNode>(STONE));
auto idDeep = g.AddNode(std::make_unique<IDNode>(DEEP_STONE));
// y < -10
auto lessDeep = g.AddNode(std::make_unique<LessNode>());
REQUIRE(g.Connect(posY, lessDeep, 0));
REQUIRE(g.Connect(negTen, lessDeep, 1));
// y < 0
auto lessZero = g.AddNode(std::make_unique<LessNode>());
REQUIRE(g.Connect(posY, lessZero, 0));
REQUIRE(g.Connect(zeroC, lessZero, 1));
// Inner branch: y < 0 → STONE else AIR
auto innerBranch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(lessZero, innerBranch, 0));
REQUIRE(g.Connect(idStone, innerBranch, 1));
REQUIRE(g.Connect(idAir, innerBranch, 2));
// Outer branch: y < -10 → DEEP_STONE else (inner result)
auto outerBranch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(lessDeep, outerBranch, 0));
REQUIRE(g.Connect(idDeep, outerBranch, 1));
REQUIRE(g.Connect(innerBranch, outerBranch, 2));
auto eval = [&](int y) {
EvalContext ctx; ctx.worldY = y;
return g.Evaluate(outerBranch, ctx).AsInt();
};
CHECK(eval(-11) == DEEP_STONE);
CHECK(eval(-10) == STONE); // -10 is not < -10
CHECK(eval(-5) == STONE);
CHECK(eval(-1) == STONE);
CHECK(eval(0) == AIR);
CHECK(eval(5) == AIR);
}
}

View File

@@ -0,0 +1,616 @@
#include <doctest/doctest.h>
#include "WorldGraph/WorldGraphChunk.h"
#include "WorldGraph/WorldGraphNode.h"
#include <cmath>
using namespace WorldGraph;
// ─────────────────────────────── TileGrid ────────────────────────────────────
TEST_SUITE("WorldGraph::TileGrid") {
TEST_CASE("Initialises every cell to 0") {
TileGrid g(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(g.Get(x, y) == 0);
}
TEST_CASE("Get/Set round-trip") {
TileGrid g(0, 0, 8, 8);
g.Set(3, 5, 42);
CHECK(g.Get(3, 5) == 42);
CHECK(g.Get(3, 4) == 0); // neighbour untouched
}
TEST_CASE("Get returns 0 for out-of-bounds") {
TileGrid g(2, 2, 4, 4); // covers (2..5, 2..5)
CHECK(g.Get(1, 3) == 0); // x too small
CHECK(g.Get(6, 3) == 0); // x too large
CHECK(g.Get(3, 1) == 0); // y too small
CHECK(g.Get(3, 6) == 0); // y too large
}
TEST_CASE("Set is a no-op for out-of-bounds") {
TileGrid g(0, 0, 4, 4);
g.Set(-1, 0, 99); // must not crash or corrupt
g.Set(0, -1, 99);
g.Set(4, 0, 99);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(g.Get(x, y) == 0);
}
TEST_CASE("Contains") {
TileGrid g(1, 2, 3, 4); // x: 1..3, y: 2..5
CHECK( g.Contains(1, 2));
CHECK( g.Contains(3, 5));
CHECK(!g.Contains(0, 3));
CHECK(!g.Contains(4, 3));
CHECK(!g.Contains(2, 1));
CHECK(!g.Contains(2, 6));
}
TEST_CASE("Origin offset: Set/Get at non-zero origin") {
TileGrid g(-5, -3, 10, 6); // world x: -5..4, y: -3..2
g.Set(-5, -3, 7);
g.Set(4, 2, 8);
CHECK(g.Get(-5, -3) == 7);
CHECK(g.Get( 4, 2) == 8);
CHECK(g.Get( 0, 0) == 0);
}
TEST_CASE("MakeEvalContext sets prev-pass fields") {
TileGrid g(0, 0, 4, 4);
g.Set(1, 2, 5);
EvalContext ctx = g.MakeEvalContext(1, 2, 42u);
CHECK(ctx.worldX == 1);
CHECK(ctx.worldY == 2);
CHECK(ctx.seed == 42u);
CHECK(ctx.prevTiles != nullptr);
CHECK(ctx.prevOriginX == 0);
CHECK(ctx.prevOriginY == 0);
CHECK(ctx.prevWidth == 4);
CHECK(ctx.prevHeight == 4);
CHECK(ctx.GetPrevTile(1, 2) == 5);
CHECK(ctx.GetPrevTile(0, 0) == 0);
}
}
// ─────────────────────────────── PaddingBounds ───────────────────────────────
TEST_SUITE("WorldGraph::PaddingBounds") {
TEST_CASE("Default is all zero") {
PaddingBounds p;
CHECK(p.IsZero());
}
TEST_CASE("Include positive offset expands posX/posY") {
PaddingBounds p;
p.Include(3, 4);
CHECK(p.negX == 0); CHECK(p.posX == 3);
CHECK(p.negY == 0); CHECK(p.posY == 4);
}
TEST_CASE("Include negative offset expands negX/negY") {
PaddingBounds p;
p.Include(-2, -5);
CHECK(p.negX == 2); CHECK(p.posX == 0);
CHECK(p.negY == 5); CHECK(p.posY == 0);
}
TEST_CASE("Include zero changes nothing") {
PaddingBounds p;
p.Include(0, 0);
CHECK(p.IsZero());
}
TEST_CASE("Include keeps max, not last") {
PaddingBounds p;
p.Include(1, 0);
p.Include(3, 0);
p.Include(2, 0);
CHECK(p.posX == 3);
}
TEST_CASE("Include(PaddingBounds) takes element-wise max") {
PaddingBounds a; a.Include(2, 1); a.Include(-3, 0);
PaddingBounds b; b.Include(1, 4); b.Include( 0, -2);
a.Include(b);
CHECK(a.negX == 3); CHECK(a.posX == 2);
CHECK(a.negY == 2); CHECK(a.posY == 4);
}
TEST_CASE("TotalX/TotalY") {
PaddingBounds p;
p.Include(-2, -3);
p.Include( 4, 5);
CHECK(p.TotalX() == 6);
CHECK(p.TotalY() == 8);
}
}
// ─────────────────────────────── ComputeRequiredPadding ──────────────────────
TEST_SUITE("WorldGraph::ComputeRequiredPadding") {
TEST_CASE("Graph with no query nodes → zero padding") {
Graph g;
auto c = g.AddNode(std::make_unique<ConstantNode>(1.0f));
CHECK(ComputeRequiredPadding(g, c).IsZero());
}
TEST_CASE("QueryTileNode contributes its offset") {
Graph g;
auto qt = g.AddNode(std::make_unique<QueryTileNode>(2, -3, 1));
auto p = ComputeRequiredPadding(g, qt);
CHECK(p.posX == 2); CHECK(p.negX == 0);
CHECK(p.negY == 3); CHECK(p.posY == 0);
}
TEST_CASE("QueryRangeNode contributes its extreme corners") {
Graph g;
// range: x in [-1,1], y in [1,4]
auto qr = g.AddNode(std::make_unique<QueryRangeNode>(-1, 1, 1, 4, 0));
auto p = ComputeRequiredPadding(g, qr);
CHECK(p.negX == 1); CHECK(p.posX == 1);
CHECK(p.negY == 0); CHECK(p.posY == 4);
}
TEST_CASE("QueryDistanceNode with maxDistance=3 pads all directions by 3") {
Graph g;
auto qd = g.AddNode(std::make_unique<QueryDistanceNode>(1, 3));
auto p = ComputeRequiredPadding(g, qd);
CHECK(p.negX == 3); CHECK(p.posX == 3);
CHECK(p.negY == 3); CHECK(p.posY == 3);
}
TEST_CASE("Multiple query nodes: takes element-wise max") {
Graph g;
// Outer branch feeds two query nodes; ComputeRequiredPadding walks all deps.
auto cond = g.AddNode(std::make_unique<QueryTileNode>( 0, 5, 1)); // posY=5
auto qtFar = g.AddNode(std::make_unique<QueryTileNode>(-4, 0, 1)); // negX=4
auto branch = 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(cond, branch, 0));
REQUIRE(g.Connect(id1, branch, 1));
REQUIRE(g.Connect(id0, branch, 2));
// qtFar is not connected to branch but IS in the graph.
// ComputeRequiredPadding only walks the subgraph reachable from outputNode.
auto p1 = ComputeRequiredPadding(g, branch);
CHECK(p1.posY == 5); // from cond
CHECK(p1.negX == 0); // qtFar not reachable from branch
// Connect qtFar into the output chain.
auto andN = g.AddNode(std::make_unique<AndNode>());
REQUIRE(g.Connect(cond, andN, 0));
REQUIRE(g.Connect(qtFar, andN, 1));
auto branch2 = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(andN, branch2, 0));
REQUIRE(g.Connect(id1, branch2, 1));
REQUIRE(g.Connect(id0, branch2, 2));
auto p2 = ComputeRequiredPadding(g, branch2);
CHECK(p2.posY == 5);
CHECK(p2.negX == 4);
}
}
// ─────────────────────────────── Query nodes ─────────────────────────────────
TEST_SUITE("WorldGraph::QueryNodes") {
// Helper: build a 4×4 grid filled with a checkerboard of STONE(1) / AIR(0).
static TileGrid MakeCheckerboard(int ox = 0, int oy = 0) {
TileGrid g(ox, oy, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
if ((x + y) % 2 == 0) g.Set(ox + x, oy + y, 1); // STONE
return g;
}
TEST_CASE("QueryTileNode: match at current cell") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0); // (0,0) is STONE
QueryTileNode n(0, 0, 1);
CHECK(n.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryTileNode: no match") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0); // (0,0) is STONE
QueryTileNode n(0, 0, 0); // looking for AIR at (0,0)
CHECK(n.Evaluate(ctx, {}).AsBool() == false);
}
TEST_CASE("QueryTileNode: relative offset") {
auto grid = MakeCheckerboard();
// (0,0)=STONE (1,0)=AIR (0,1)=AIR (1,1)=STONE
EvalContext ctx = grid.MakeEvalContext(0, 0, 0);
QueryTileNode at1_0(1, 0, 0); // should be AIR
CHECK(at1_0.Evaluate(ctx, {}).AsBool() == true);
QueryTileNode at1_1(1, 1, 1); // should be STONE
CHECK(at1_1.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryTileNode: out-of-bounds returns 0 (AIR)") {
auto grid = MakeCheckerboard();
EvalContext ctx = grid.MakeEvalContext(0, 0, 0);
QueryTileNode oob(10, 10, 0); // offset way out of bounds → GetPrevTile → 0 (AIR)
CHECK(oob.Evaluate(ctx, {}).AsBool() == true);
}
TEST_CASE("QueryRangeNode: count matching tiles in range") {
TileGrid g(0, 0, 5, 5);
// Fill column x=2, y=0..4 with STONE(1)
for (int y = 0; y < 5; ++y) g.Set(2, y, 1);
// At cell (2, 2): range (-1,1) to (1,1) → 3 STONE tiles in column
EvalContext ctx = g.MakeEvalContext(2, 2, 0);
QueryRangeNode qr(-1, -1, 1, 1, 1);
CHECK(qr.Evaluate(ctx, {}).AsInt() == 3); // (2,1),(2,2),(2,3)
}
TEST_CASE("QueryRangeNode: count above — column of AIR above stone") {
TileGrid g(0, 0, 3, 8);
// Stone layer: y=0..3, air above: y=4..7
for (int x = 0; x < 3; ++x)
for (int y = 0; y < 4; ++y)
g.Set(x, y, 1); // STONE
// At cell (1, 3) (top stone): range (0,1..4) should find 4 AIR tiles above
EvalContext ctx = g.MakeEvalContext(1, 3, 0);
QueryRangeNode above(0, 1, 0, 4, 0); // count AIR in y+1..y+4
CHECK(above.Evaluate(ctx, {}).AsInt() == 4);
// At cell (1, 0) (deep stone): only y=1..4 checked, y=1..3 are stone, y=4 is air
EvalContext ctx2 = g.MakeEvalContext(1, 0, 0);
CHECK(above.Evaluate(ctx2, {}).AsInt() == 1); // only y=4 is air
}
TEST_CASE("QueryDistanceNode: finds adjacent tile at distance 1") {
TileGrid g(0, 0, 5, 5);
g.Set(2, 3, 1); // STONE one tile above current cell (1,3) → actually (2,3) is to the right of (1,3)
// Place STONE directly above (1,1) at (1,2)
g.Set(1, 2, 1);
EvalContext ctx = g.MakeEvalContext(1, 1, 0); // current = (1,1)
QueryDistanceNode qd(1, 4);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 1); // (1,2) is Chebyshev distance 1
}
TEST_CASE("QueryDistanceNode: not found returns maxDistance+1") {
TileGrid g(0, 0, 5, 5); // all AIR
EvalContext ctx = g.MakeEvalContext(2, 2, 0);
QueryDistanceNode qd(1 /*STONE*/, 3);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 4); // maxDistance+1
}
TEST_CASE("QueryDistanceNode: no previous pass returns maxDistance+1") {
EvalContext ctx; ctx.worldX = 0; ctx.worldY = 0;
QueryDistanceNode qd(1, 4);
CHECK(qd.Evaluate(ctx, {}).AsInt() == 5);
}
TEST_CASE("QueryDistanceNode: ignores current cell") {
TileGrid g(0, 0, 3, 3);
g.Set(1, 1, 1); // STONE at current cell
EvalContext ctx = g.MakeEvalContext(1, 1, 0);
QueryDistanceNode qd(1, 2); // looking for STONE
// (1,1) is self, must be skipped; no other STONE tiles → maxDistance+1
CHECK(qd.Evaluate(ctx, {}).AsInt() == 3);
}
}
// ─────────────────────────────── GenerateRegion ──────────────────────────────
TEST_SUITE("WorldGraph::GenerateRegion") {
// Constant tile: every cell gets IDNode(7).
TEST_CASE("Single pass, constant tile ID") {
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(7));
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, nullptr, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 7);
}
// Flat stone layer: stone (ID=1) where worldY < 4, air otherwise.
TEST_CASE("Single pass, flat stone layer at y < 4") {
const int32_t STONE = 1;
Graph g;
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto thresh = g.AddNode(std::make_unique<ConstantNode>(4.0f));
auto less = g.AddNode(std::make_unique<LessNode>());
auto stone = g.AddNode(std::make_unique<IDNode>(STONE));
auto air = g.AddNode(std::make_unique<IDNode>(0));
auto branch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(thresh, less, 1));
REQUIRE(g.Connect(less, branch, 0));
REQUIRE(g.Connect(stone, branch, 1));
REQUIRE(g.Connect(air, branch, 2));
auto grid = GenerateRegion(g, branch, 0, 0, 8, 8, nullptr, 0);
CHECK(grid.originX == 0); CHECK(grid.originY == 0);
CHECK(grid.width == 8); CHECK(grid.height == 8);
for (int y = 0; y < 8; ++y) {
for (int x = 0; x < 8; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(grid.Get(x, y) == (y < 4 ? STONE : 0));
}
}
}
// "No change" semantics: pass returns 0 → keep previous pass value.
TEST_CASE("Zero return keeps previous pass value") {
// Previous pass: all STONE (1).
TileGrid prev(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
prev.Set(x, y, 1);
// Current pass: always returns 0 (no change).
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(0));
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, &prev, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 1); // kept from prev
}
// Non-zero pass: overrides previous pass.
TEST_CASE("Non-zero return overrides previous pass") {
TileGrid prev(0, 0, 4, 4);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
prev.Set(x, y, 1); // STONE
Graph g;
auto out = g.AddNode(std::make_unique<IDNode>(2)); // always DIRT
auto grid = GenerateRegion(g, out, 0, 0, 4, 4, &prev, 0);
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x)
CHECK(grid.Get(x, y) == 2);
}
}
// ─────────────────────────────── 16×16 chunk tests ───────────────────────────
// Tile IDs used throughout.
static constexpr int32_t AIR = 0;
static constexpr int32_t STONE = 1;
static constexpr int32_t DIRT = 2;
// ─── Sine-wave stone pass ─────────────────────────────────────────────────────
//
// stone if worldY < sin(worldX * 0.5) * 3 + 8.0
//
// Graph: [PosX] → mul(0.5) → Sin → mul(3.0) → add(8.0) → threshold
// [PosY] ──────────────────────────────────────────→ Less(y, threshold)
// → Branch(condition, ID(STONE), ID(AIR))
static Graph::NodeID BuildSineStoneGraph(Graph& g) {
auto posX = g.AddNode(std::make_unique<PositionXNode>());
auto posY = g.AddNode(std::make_unique<PositionYNode>());
auto freq = g.AddNode(std::make_unique<ConstantNode>(0.5f));
auto amp = g.AddNode(std::make_unique<ConstantNode>(3.0f));
auto bias = g.AddNode(std::make_unique<ConstantNode>(8.0f));
auto mul1 = g.AddNode(std::make_unique<MultiplyNode>());
auto sinN = g.AddNode(std::make_unique<SinNode>());
auto mul2 = g.AddNode(std::make_unique<MultiplyNode>());
auto addN = 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, mul1, 0));
REQUIRE(g.Connect(freq, mul1, 1));
REQUIRE(g.Connect(mul1, sinN, 0));
REQUIRE(g.Connect(sinN, mul2, 0));
REQUIRE(g.Connect(amp, mul2, 1));
REQUIRE(g.Connect(mul2, addN, 0));
REQUIRE(g.Connect(bias, addN, 1));
REQUIRE(g.Connect(posY, less, 0));
REQUIRE(g.Connect(addN, less, 1));
REQUIRE(g.Connect(less, branch, 0));
REQUIRE(g.Connect(stone, branch, 1));
REQUIRE(g.Connect(air, branch, 2));
return branch;
}
// Expected stone predicate — mirrors the graph exactly.
static bool IsStoneExpected(int worldX, int worldY) {
float threshold = std::sin(worldX * 0.5f) * 3.0f + 8.0f;
return static_cast<float>(worldY) < threshold;
}
TEST_SUITE("WorldGraph::Chunks_16x16") {
TEST_CASE("Pass 0: sine-wave stone layer matches expected predicate") {
Graph g;
auto branch = BuildSineStoneGraph(g);
auto grid = GenerateRegion(g, branch, 0, 0, 16, 16, nullptr, 0);
for (int y = 0; y < 16; ++y) {
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
bool expectedStone = IsStoneExpected(x, y);
CHECK(grid.Get(x, y) == (expectedStone ? STONE : AIR));
}
}
}
TEST_CASE("Pass 0: non-zero-origin chunk — same predicate, different coords") {
Graph g;
auto branch = BuildSineStoneGraph(g);
// Generate at world offset (-8, -8) so tile (i,j) is at world (-8+i, -8+j).
auto grid = GenerateRegion(g, branch, -8, -8, 16, 16, nullptr, 0);
for (int ly = 0; ly < 16; ++ly) {
for (int lx = 0; lx < 16; ++lx) {
int wx = -8 + lx, wy = -8 + ly;
INFO("wx=" << wx << " wy=" << wy);
bool expectedStone = IsStoneExpected(wx, wy);
CHECK(grid.Get(wx, wy) == (expectedStone ? STONE : AIR));
}
}
}
// ─── Two-pass: sine-wave stone + dirt top layer ───────────────────────────
//
// Pass 1 (dirt): for each cell in the chunk
// - QueryTile (0, 0) == STONE (is current cell stone in pass 0?)
// - QueryRange (0, 1..4) counts AIR tiles directly above
// - If both: return DIRT, else: return 0 (no change)
//
// Expected: the topmost N≤4 stone tiles in each column become dirt.
static Graph::NodeID BuildDirtGraph(Graph& g) {
// Condition 1: current cell is STONE in the previous pass.
auto isStone = g.AddNode(std::make_unique<QueryTileNode>(0, 0, STONE));
// Condition 2: any of the 4 tiles directly above are AIR in the previous pass.
auto airAboveCount = g.AddNode(std::make_unique<QueryRangeNode>(0, 1, 0, 4, AIR));
auto zero = g.AddNode(std::make_unique<ConstantNode>(0.0f));
auto hasAirAbove = g.AddNode(std::make_unique<GreaterNode>());
REQUIRE(g.Connect(airAboveCount, hasAirAbove, 0));
REQUIRE(g.Connect(zero, hasAirAbove, 1));
// Both conditions must be true.
auto andN = g.AddNode(std::make_unique<AndNode>());
REQUIRE(g.Connect(isStone, andN, 0));
REQUIRE(g.Connect(hasAirAbove, andN, 1));
// Branch: true → DIRT, false → 0 (no change).
auto dirt = g.AddNode(std::make_unique<IDNode>(DIRT));
auto noChange = g.AddNode(std::make_unique<IDNode>(0));
auto branch = g.AddNode(std::make_unique<BranchNode>());
REQUIRE(g.Connect(andN, branch, 0));
REQUIRE(g.Connect(dirt, branch, 1));
REQUIRE(g.Connect(noChange, branch, 2));
return branch;
}
TEST_CASE("Padding: dirt pass needs 4 tiles of padding above") {
Graph g;
auto branch = BuildDirtGraph(g);
auto p = ComputeRequiredPadding(g, branch);
CHECK(p.negX == 0); CHECK(p.posX == 0);
CHECK(p.negY == 0); CHECK(p.posY == 4); // QueryRange up to +4
}
TEST_CASE("Two-pass GenerateChunk: dirt layer appears on top of stone") {
Graph stoneGraph, dirtGraph;
auto stoneBranch = BuildSineStoneGraph(stoneGraph);
auto dirtBranch = BuildDirtGraph(dirtGraph);
auto chunk = GenerateChunk(
{ { stoneGraph, stoneBranch }, { dirtGraph, dirtBranch } },
0, 0, 16, 16, 0);
CHECK(chunk.originX == 0); CHECK(chunk.originY == 0);
CHECK(chunk.width == 16); CHECK(chunk.height == 16);
for (int x = 0; x < 16; ++x) {
// Find the stone boundary for this column.
// IsStoneExpected(x, y) = (y < sin(x*0.5)*3+8), so the topmost stone row
// is floor(threshold) or threshold-1 depending on fractions.
// Collect per-column stone tiles.
int topStoneY = -1;
for (int y = 15; y >= 0; --y) {
if (IsStoneExpected(x, y)) { topStoneY = y; break; }
}
if (topStoneY < 0) continue; // all air in this column
// Cells y=0 .. max(0, topStoneY-4) should be STONE (too deep for air above).
// Cells y=(topStoneY-3) .. topStoneY should be DIRT (air within 4 above).
// (The exact boundary depends on how many stone tiles sit above the 4-tile window.)
// Simple invariant: the topmost stone tile must be DIRT.
{
INFO("x=" << x << " topStoneY=" << topStoneY);
CHECK(chunk.Get(x, topStoneY) == DIRT);
}
// Verify no DIRT appears in the air zone.
for (int y = topStoneY + 1; y < 16; ++y) {
INFO("air zone x=" << x << " y=" << y);
CHECK(chunk.Get(x, y) == AIR);
}
// Verify tiles well below the surface (>4 below topStoneY) are still STONE.
if (topStoneY > 4) {
INFO("deep stone x=" << x << " y=0");
CHECK(chunk.Get(x, 0) == STONE);
}
}
}
TEST_CASE("Two-pass: stone-pass grid covers padded region for dirt pass") {
// Validate that GenerateChunk computed the right regions by checking
// that the intermediate pass 0 output would cover the needed range.
// We do this by comparing the single-pass stone output for the padded
// region against what GenerateChunk provides.
Graph stoneGraph, dirtGraph;
auto stoneBranch = BuildSineStoneGraph(stoneGraph);
auto dirtBranch = BuildDirtGraph(dirtGraph);
// The dirt graph needs posY = 4 padding → pass 0 must cover y=0..19 for chunk y=0..15.
auto padding = ComputeRequiredPadding(dirtGraph, dirtBranch);
CHECK(padding.posY == 4);
// Generate the padded stone layer manually and verify it covers y=0..19.
auto paddedStone = GenerateRegion(stoneGraph, stoneBranch,
0, 0, 16, 16 + padding.TotalY(), nullptr, 0);
CHECK(paddedStone.height == 20);
CHECK(paddedStone.originY == 0);
// Re-run the dirt pass using our manually padded stone grid.
auto dirtGrid = GenerateRegion(dirtGraph, dirtBranch,
0, 0, 16, 16, &paddedStone, 0);
// And compare against GenerateChunk output.
auto chunkGrid = GenerateChunk(
{ { stoneGraph, stoneBranch }, { dirtGraph, dirtBranch } },
0, 0, 16, 16, 0);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(chunkGrid.Get(x, y) == dirtGrid.Get(x, y));
}
}
TEST_CASE("Single-pass GenerateChunk equals GenerateRegion") {
Graph g;
auto branch = BuildSineStoneGraph(g);
auto chunkGrid = GenerateChunk({ { g, branch } }, 0, 0, 16, 16, 0);
auto regionGrid = GenerateRegion(g, branch, 0, 0, 16, 16, nullptr, 0);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x) {
INFO("x=" << x << " y=" << y);
CHECK(chunkGrid.Get(x, y) == regionGrid.Get(x, y));
}
}
TEST_CASE("GenerateChunk: empty pass list returns all-zero grid") {
auto grid = GenerateChunk({}, 5, 10, 16, 16, 0);
CHECK(grid.originX == 5); CHECK(grid.originY == 10);
CHECK(grid.width == 16); CHECK(grid.height == 16);
for (int y = 0; y < 16; ++y)
for (int x = 0; x < 16; ++x)
CHECK(grid.Get(5 + x, 10 + y) == 0);
}
}

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

@@ -0,0 +1,65 @@
find_package(OpenGL REQUIRED)
include(FetchContent)
# ── ImGui static library ──────────────────────────────────────────────────────
add_library(imgui_lib STATIC
${imgui_SOURCE_DIR}/imgui.cpp
${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp
${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_glfw.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp
)
target_include_directories(imgui_lib PUBLIC
${imgui_SOURCE_DIR}
${imgui_SOURCE_DIR}/backends
)
target_link_libraries(imgui_lib PUBLIC glfw OpenGL::GL)
target_compile_definitions(imgui_lib PUBLIC IMGUI_DEFINE_MATH_OPERATORS)
# ── imnodes static library ────────────────────────────────────────────────────
add_library(imnodes_lib STATIC
${imnodes_SOURCE_DIR}/imnodes.cpp
)
target_include_directories(imnodes_lib PUBLIC
${imnodes_SOURCE_DIR}
)
target_link_libraries(imnodes_lib PUBLIC imgui_lib)
# ── ImGuiFileDialog static library ───────────────────────────────────────────
# Declared in root CMakeLists.txt; populated here so we build only the sources
# we need without running ImGuiFileDialog's own CMakeLists.txt.
FetchContent_GetProperties(ImGuiFileDialog)
if(NOT imguifiledialog_POPULATED)
FetchContent_Populate(ImGuiFileDialog)
endif()
add_library(imgui_file_dialog_lib STATIC
${imguifiledialog_SOURCE_DIR}/ImGuiFileDialog.cpp
)
target_include_directories(imgui_file_dialog_lib PUBLIC
${imguifiledialog_SOURCE_DIR}
)
target_link_libraries(imgui_file_dialog_lib PUBLIC imgui_lib)
# ── Node editor executable ────────────────────────────────────────────────────
add_executable(node-editor
main.cpp
)
target_link_libraries(node-editor PRIVATE
factory-hole-core
imgui_lib
imnodes_lib
imgui_file_dialog_lib
)

143
tools/node-editor/README.md Normal file
View File

@@ -0,0 +1,143 @@
# WorldGraph Node Editor
A standalone visual node editor for the WorldGraph procedural generation system, built with Dear ImGui and imnodes. Every node displays a live 64×64 preview of its output, ShaderGraph-style.
## Building & Running
```bash
make world-editor
```
This configures, builds, and launches the editor in one step.
---
## Interface
### Layout
| Area | Description |
|---|---|
| Left sidebar | Preview settings, World Output status, help |
| Main canvas | Node graph — pan with middle-mouse, zoom with scroll |
### Adding Nodes
Right-click anywhere on the empty canvas to open the node menu, grouped by category. The node spawns at the click position.
### Connecting Nodes
Drag from an **output pin** (right side of a node) to an **input pin** (left side). Dragging in either direction works. Connecting to an already-wired input replaces the existing connection. Cycles are rejected automatically.
### Disconnecting
Click an existing link to select it, then press **Delete**. Or drag a new connection onto the same input slot to replace it.
### Deleting Nodes
Select one or more nodes and press **Delete**. The **World Output** node cannot be deleted.
### Keyboard Shortcuts
| Key | Action |
|---|---|
| `Ctrl+S` | Save |
| `Ctrl+O` | Open |
| `Delete` | Delete selected nodes or links |
---
## World Output Node
The **World Output** node (blue title bar) is the fixed endpoint of the graph. It is always present and cannot be moved off-canvas or deleted.
Each input slot represents one **generation pass**. Passes execute in order — pass 1 can read the tile output of pass 0 via `QueryTile`, `QueryRange`, and `QueryDistance` nodes.
- **Connect a pass:** drag any node's output pin into a `Pass N` slot.
- **Add a pass slot:** click `+ Pass` inside the node.
- **Remove the last pass slot:** click `- Pass`.
The World Output preview shows the result of running `GenerateChunk()` with all connected passes over a 64×64 tile region starting at the preview origin. Each pixel equals one world tile.
---
## Per-Node Previews
Every node in the graph renders its own 64×64 preview by evaluating the subgraph rooted at that node across a grid of world coordinates.
| Output type | Preview color |
|---|---|
| `Float` | Grayscale, clamped to `[0, 1]` |
| `Bool` | White (true) / black (false) |
| `Int` (tile ID) | Hashed to a distinct hue; tile 0 (AIR) = near-black |
**Scale** in the sidebar controls how many world units one pixel represents in per-node previews. This lets you zoom in on high-frequency noise or zoom out to see large-scale structure.
> Query nodes (`QueryTile`, `QueryRange`, `QueryDistance`) always preview as blank in the per-node view because no previous-pass data is available at that stage. They work correctly in the World Output preview when used in a later pass.
---
## Preview Settings (Sidebar)
| Setting | Effect |
|---|---|
| **Origin X / Y** | World-space top-left corner of all previews |
| **Scale** | World units per pixel (per-node previews only; World Output is always 1 tile/pixel) |
| **Seed** | World seed passed to every `EvalContext` |
---
## Node Types
### Source
| Node | Output | Description |
|---|---|---|
| `Constant` | Float | Fixed float value (drag to edit) |
| `TileID` | Int | Fixed tile ID integer (drag to edit) |
| `PositionX` | Int | World X coordinate of the current cell |
| `PositionY` | Int | World Y coordinate of the current cell |
### Math
`Add`, `Subtract`, `Multiply`, `Divide`, `Modulo`, `Sin`, `Cos`
### Compare
`Less`, `Greater`, `LessEqual`, `GreaterEqual`, `Equal` — all output `Bool`
### Logic
`And`, `Or`, `Not`
### Control
| Node | Inputs | Description |
|---|---|---|
| `Branch` | condition (Bool), true, false | Passes through whichever branch the condition selects |
### Query (previous pass)
These nodes read from the previous generation pass. They have no input pins — their parameters are edited inline by dragging.
| Node | Output | Parameters |
|---|---|---|
| `QueryTile` | Bool | `offsetX`, `offsetY`, `expectedID` — true if prev-pass tile at offset equals ID |
| `QueryRange` | Int | `minX..maxX`, `minY..maxY`, `tileID` — count of matching tiles in rectangle |
| `QueryDistance` | Int | `tileID`, `maxDistance` — Chebyshev distance to nearest matching tile |
---
## File Format
Graphs are saved as `.wge` JSON files containing both the graph data and editor layout:
```json
{
"graph": { ... },
"editor": {
"nodePositions": { "1": [x, y], "__worldOutput": [x, y] },
"worldOutputPasses": [3, 7],
"seed": 0,
"previewOriginX": 0,
"previewOriginY": 0,
"previewScale": 1.0
}
}
```
`worldOutputPasses` is an array of graph node IDs, one per pass slot (`0` = empty slot).

1605
tools/node-editor/main.cpp Normal file

File diff suppressed because it is too large Load Diff