Compare commits
7 Commits
cd745f0eda
...
bee5aa0e8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bee5aa0e8f | ||
|
|
1b7fd1c7f8 | ||
|
|
1e6a8d4f60 | ||
|
|
376442e95a | ||
|
|
3d4453b9ea | ||
|
|
7b80eda561 | ||
|
|
9b0c9a87fa |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
103
CMakeLists.txt
103
CMakeLists.txt
@@ -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
26
Makefile
Normal 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
650
data/WorldGraphs/dirt.json
Normal 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
100
data/WorldGraphs/stone.json
Normal 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
58
data/tiles.json
Normal 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
101
imgui.ini
Normal 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
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
|
||||
@@ -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{};
|
||||
};
|
||||
@@ -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);
|
||||
// }
|
||||
// };
|
||||
@@ -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
213
include/WorldGraph/README.md
Normal file
213
include/WorldGraph/README.md
Normal 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);
|
||||
```
|
||||
114
include/WorldGraph/WorldGraph.h
Normal file
114
include/WorldGraph/WorldGraph.h
Normal 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
|
||||
116
include/WorldGraph/WorldGraphChunk.h
Normal file
116
include/WorldGraph/WorldGraphChunk.h
Normal 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
|
||||
839
include/WorldGraph/WorldGraphNode.h
Normal file
839
include/WorldGraph/WorldGraphNode.h
Normal 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
|
||||
56
include/WorldGraph/WorldGraphSerializer.h
Normal file
56
include/WorldGraph/WorldGraphSerializer.h
Normal 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
|
||||
104
include/WorldGraph/WorldGraphTypes.h
Normal file
104
include/WorldGraph/WorldGraphTypes.h
Normal 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
|
||||
115
src/WorldGraph/WorldGraph.cpp
Normal file
115
src/WorldGraph/WorldGraph.cpp
Normal 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
|
||||
186
src/WorldGraph/WorldGraphChunk.cpp
Normal file
186
src/WorldGraph/WorldGraphChunk.cpp
Normal 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
|
||||
242
src/WorldGraph/WorldGraphSerializer.cpp
Normal file
242
src/WorldGraph/WorldGraphSerializer.cpp
Normal 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
|
||||
468
tests/WorldGraph/test_GraphNodes.cpp
Normal file
468
tests/WorldGraph/test_GraphNodes.cpp
Normal 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()));
|
||||
}
|
||||
}
|
||||
253
tests/WorldGraph/test_GraphSerialization.cpp
Normal file
253
tests/WorldGraph/test_GraphSerialization.cpp
Normal 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());
|
||||
}
|
||||
}
|
||||
452
tests/WorldGraph/test_WorldGraph.cpp
Normal file
452
tests/WorldGraph/test_WorldGraph.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
616
tests/WorldGraph/test_WorldGraphChunk.cpp
Normal file
616
tests/WorldGraph/test_WorldGraphChunk.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
667
tests/WorldGraph/test_liquid.cpp
Normal file
667
tests/WorldGraph/test_liquid.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tools/node-editor/CMakeLists.txt
Normal file
65
tools/node-editor/CMakeLists.txt
Normal 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
143
tools/node-editor/README.md
Normal 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
1605
tools/node-editor/main.cpp
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user