No files matched your search
@@ -128,7 +128,8 @@ APK="$(find "$WORK/apk-out" -name '*.apk' | head -1)"
|
||||
import sys, zipfile
|
||||
z = zipfile.ZipFile(sys.argv[1])
|
||||
names = set(z.namelist())
|
||||
need = {"lib/arm64-v8a/libmain.so", "lib/arm64-v8a/libc++_shared.so",
|
||||
need = {"lib/arm64-v8a/libmain.so", "lib/arm64-v8a/libSDL2.so",
|
||||
"lib/arm64-v8a/libc++_shared.so",
|
||||
"assets/game/main.nut", "assets/game/config.nut"}
|
||||
missing = need - names
|
||||
if missing:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
apk_dir="${1:-apk}"
|
||||
expected_count="${2:-3}"
|
||||
success_marker="${3:-EVE_CI_GAME_OK}"
|
||||
expected_abi="${4:-arm64-v8a}"
|
||||
mapfile -t apk_files < <(find "$apk_dir" -maxdepth 1 -type f -name '*.apk' -print | sort)
|
||||
|
||||
if [[ "${#apk_files[@]}" -ne "$expected_count" ]]; then
|
||||
echo "ERROR: expected $expected_count APKs, found ${#apk_files[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Emulator ABI list: $(adb shell getprop ro.product.cpu.abilist)"
|
||||
|
||||
build_tools_dir="$(find "${ANDROID_HOME:?ANDROID_HOME is required}/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -1)"
|
||||
zipalign="$build_tools_dir/zipalign"
|
||||
apksigner="$build_tools_dir/apksigner"
|
||||
if [[ ! -x "$zipalign" || ! -x "$apksigner" ]]; then
|
||||
echo "ERROR: zipalign/apksigner not found under $build_tools_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signing_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$signing_dir"' EXIT
|
||||
keytool -genkeypair \
|
||||
-keystore "$signing_dir/ci-test.keystore" \
|
||||
-storepass android -keypass android -alias androiddebugkey \
|
||||
-dname "CN=EVEngine CI,OU=CI,O=EVEngine,L=CI,ST=CI,C=US" \
|
||||
-keyalg RSA -keysize 2048 -validity 1 -noprompt >/dev/null 2>&1
|
||||
|
||||
for apk_file in "${apk_files[@]}"; do
|
||||
apk_name="$(basename "$apk_file")"
|
||||
echo "Testing $apk_name"
|
||||
|
||||
if ! unzip -Z1 "$apk_file" | grep -E "^lib/${expected_abi}/.+\\.so$" >/dev/null; then
|
||||
echo "ERROR: $apk_name does not contain $expected_abi native libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$expected_abi" == "arm64-v8a" ]] && unzip -Z1 "$apk_file" | grep -E '^lib/(x86|x86_64)/' >/dev/null; then
|
||||
echo "ERROR: $apk_name unexpectedly contains x86 native libraries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
aligned_apk="$signing_dir/${apk_name%.apk}-aligned.apk"
|
||||
signed_apk="$signing_dir/${apk_name%.apk}-signed.apk"
|
||||
"$zipalign" -f -p 4 "$apk_file" "$aligned_apk"
|
||||
"$apksigner" sign \
|
||||
--ks "$signing_dir/ci-test.keystore" \
|
||||
--ks-pass pass:android --key-pass pass:android \
|
||||
--out "$signed_apk" "$aligned_apk"
|
||||
"$apksigner" verify "$signed_apk"
|
||||
|
||||
adb logcat -c
|
||||
adb install -r "$signed_apk"
|
||||
adb shell am force-stop com.evengine.example || true
|
||||
adb shell am start -W -n com.evengine.example/.EVEngineActivity
|
||||
|
||||
ok=0
|
||||
for _ in $(seq 1 90); do
|
||||
if adb logcat -d | grep -F "$success_marker" >/dev/null; then
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$ok" -ne 1 ]]; then
|
||||
echo "ERROR: $apk_name did not print $success_marker" >&2
|
||||
echo "Application process state:" >&2
|
||||
adb shell ps -A | grep -E 'com\.evengine\.example|PID' >&2 || true
|
||||
echo "Relevant logcat entries:" >&2
|
||||
adb logcat -d -v threadtime \
|
||||
| grep -Ei 'com\.evengine\.example|EVEngine|SDL|AndroidRuntime|DEBUG|libc|linker|native.?bridge|EVE_CI' \
|
||||
| tail -n 300 >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: $apk_name printed $success_marker on the emulator"
|
||||
done
|
||||
@@ -1,12 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-sdk.sh <sdk-root> <platform> [expected-version]
|
||||
# test-sdk.sh <sdk-root> <platform> [expected-version] [android-abi]
|
||||
#
|
||||
# Verify a freshly-built target-platform SDK is usable by consumers, before it is
|
||||
# zipped and published to a GitHub Release. Runs on the same host as the SDK:
|
||||
# win32 -> bin/eve.exe (Windows runner)
|
||||
# linux -> bin/eve (Linux runner)
|
||||
# macosx-> bin/eve (macOS runner)
|
||||
# android/ios -> skipped (no host runtime binary)
|
||||
# android -> packaged APK assembled from the installed SDK
|
||||
# ios -> installed device app bundle validated as an arm64 Mach-O
|
||||
#
|
||||
# [expected-version] (e.g. "0.1.0") makes the version checks exact: the
|
||||
# share/eve/VERSION marker must equal it and `eve -v` must contain it.
|
||||
@@ -19,7 +20,7 @@
|
||||
# 4. `eve package --sdk` assembles a runnable game folder containing the runtime
|
||||
# + game.eve (and, on Windows, the runtime DLLs).
|
||||
#
|
||||
# Best-effort (WARN only, never blocks the release): try to run the packaged game
|
||||
# Best-effort (WARN only, never blocks the release): try to run the desktop packaged game
|
||||
# from memory and check for the game's startup marker. Window/Vulkan init depends
|
||||
# on a display / software ICD, so failures there are reported, not fatal.
|
||||
set -euo pipefail
|
||||
@@ -33,11 +34,12 @@ case "$SDK" in
|
||||
esac
|
||||
PLAT="${2:?usage: test-sdk.sh <sdk-root> <platform> [expected-version]}"
|
||||
EXPECTED="${3:-}"
|
||||
ANDROID_PACKAGE_ABI="${4:-arm64-v8a}"
|
||||
|
||||
case "$PLAT" in
|
||||
win32) RUNTIME="eve.exe" ;;
|
||||
linux|macosx) RUNTIME="eve" ;;
|
||||
android) RUNTIME="" ;; # verified by the APK smoke below
|
||||
android|ios) RUNTIME="" ;; # verified by target-specific gates below
|
||||
*) echo "SKIP: no host runtime to test for platform '$PLAT'"; exit 0 ;;
|
||||
esac
|
||||
|
||||
@@ -50,8 +52,7 @@ trap 'rm -rf "$WORK"' EXIT
|
||||
# Hard gate: the template + SDK libs + a minimal game must produce an APK.
|
||||
if [ "$PLAT" = "android" ]; then
|
||||
if [ -z "${ANDROID_SDK:-}" ] || [ -z "${JAVA_HOME:-}" ]; then
|
||||
echo "SKIP: android APK smoke needs ANDROID_SDK + JAVA_HOME in the environment"
|
||||
exit 0
|
||||
fail "android APK smoke needs ANDROID_SDK + JAVA_HOME in the environment"
|
||||
fi
|
||||
[ -f "$SDK/share/eve/TARGET_PLATFORM" ] || fail "missing share/eve/TARGET_PLATFORM"
|
||||
[ "$(cat "$SDK/share/eve/TARGET_PLATFORM")" = "android" ] || fail "TARGET_PLATFORM != android"
|
||||
@@ -74,13 +75,15 @@ if [ "$PLAT" = "android" ]; then
|
||||
fail "android template launcher must be EVEngineActivity (EVTestActivity has MAIN)"
|
||||
fi
|
||||
|
||||
JNI="$TEMPLATE/app/src/main/jniLibs/arm64-v8a"
|
||||
JNI="$TEMPLATE/app/src/main/jniLibs/$ANDROID_PACKAGE_ABI"
|
||||
mkdir -p "$JNI"
|
||||
[ -f "$SDK/lib/libmain.so" ] || fail "missing lib/libmain.so"
|
||||
cp "$SDK/lib/libmain.so" "$JNI/"
|
||||
for lib in libSDL2.so libhidapi.so libc++_shared.so; do
|
||||
[ -f "$SDK/lib/$lib" ] && cp "$SDK/lib/$lib" "$JNI/"
|
||||
for lib in libSDL2.so libc++_shared.so; do
|
||||
[ -f "$SDK/lib/$lib" ] || fail "missing lib/$lib"
|
||||
cp "$SDK/lib/$lib" "$JNI/"
|
||||
done
|
||||
[ ! -f "$SDK/lib/libhidapi.so" ] || cp "$SDK/lib/libhidapi.so" "$JNI/"
|
||||
|
||||
GAME="$TEMPLATE/app/src/main/assets/game"
|
||||
mkdir -p "$GAME"
|
||||
@@ -94,10 +97,9 @@ EOF
|
||||
printf 'sdk.dir=%s\n' "$ANDROID_SDK" > "$TEMPLATE/local.properties"
|
||||
|
||||
echo "== [test-sdk] assembling APK from $TEMPLATE =="
|
||||
( cd "$TEMPLATE" && sh gradlew :app:assembleDebug --no-daemon --console=plain ) \
|
||||
>"$WORK/apk-build.log" 2>&1
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
if ! ( cd "$TEMPLATE" && bash gradlew :app:assembleDebug \
|
||||
-PevengineAbi="$ANDROID_PACKAGE_ABI" --no-daemon --console=plain ) \
|
||||
>"$WORK/apk-build.log" 2>&1; then
|
||||
tail -n 40 "$WORK/apk-build.log"
|
||||
fail "gradle assembleDebug failed"
|
||||
fi
|
||||
@@ -108,6 +110,38 @@ EOF
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- iOS: validate the installed device SDK app bundle -----------------------
|
||||
# The release SDK contains an iphoneos app and cannot run in a simulator. The
|
||||
# separate ios-sim workflow job is the runtime gate; this is the artifact gate
|
||||
# that catches a missing executable, wrong architecture, malformed bundle, or
|
||||
# version/target marker before the SDK is zipped.
|
||||
if [ "$PLAT" = "ios" ]; then
|
||||
[ -f "$SDK/share/eve/TARGET_PLATFORM" ] || fail "missing share/eve/TARGET_PLATFORM"
|
||||
[ "$(cat "$SDK/share/eve/TARGET_PLATFORM")" = "ios" ] || fail "TARGET_PLATFORM != ios"
|
||||
[ -f "$SDK/share/eve/VERSION" ] || fail "missing share/eve/VERSION"
|
||||
VERSION_FILE="$(cat "$SDK/share/eve/VERSION")"
|
||||
if [ -n "$EXPECTED" ] && [ "$VERSION_FILE" != "$EXPECTED" ]; then
|
||||
fail "share/eve/VERSION '$VERSION_FILE' != expected '$EXPECTED'"
|
||||
fi
|
||||
|
||||
APP="$SDK/bin/eve.app"
|
||||
PLIST="$APP/Info.plist"
|
||||
[ -d "$APP" ] || fail "ios SDK missing bin/eve.app"
|
||||
[ -f "$PLIST" ] || fail "ios SDK missing eve.app/Info.plist"
|
||||
command -v /usr/libexec/PlistBuddy >/dev/null 2>&1 || fail "PlistBuddy unavailable"
|
||||
EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$PLIST")"
|
||||
[ -n "$EXECUTABLE" ] || fail "CFBundleExecutable is empty"
|
||||
BINARY="$APP/$EXECUTABLE"
|
||||
[ -f "$BINARY" ] || fail "ios SDK missing app executable '$EXECUTABLE'"
|
||||
file "$BINARY" | grep -q 'Mach-O' || fail "ios app executable is not Mach-O"
|
||||
lipo -archs "$BINARY" | tr ' ' '\n' | grep -qx arm64 || fail "ios app executable has no arm64 slice"
|
||||
[ -d "$SDK/include/eve" ] || fail "ios SDK missing include/eve"
|
||||
[ -d "$SDK/platform" ] || fail "ios SDK missing platform template"
|
||||
echo "OK: ios device SDK bundle is complete (executable=$EXECUTABLE, arch=arm64)"
|
||||
echo "== [test-sdk] PASS (ios) =="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== [test-sdk] platform=$PLAT sdk=$SDK runtime=$RUNTIME =="
|
||||
|
||||
# --- 1. SDK layout + markers ---------------------------------------------------
|
||||
|
||||
@@ -151,6 +151,14 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: python3 scripts/check_test_manifest.py
|
||||
|
||||
- name: Check examples layout and overview registration
|
||||
# Every examples/<name>/ is either a runnable example (main.nut +
|
||||
# config.nut) or explicitly exempted with a reason, owns a README.md and
|
||||
# is listed in examples/README.md. A new example therefore cannot dodge
|
||||
# the runtime smoke test in the Linux lane by simply not being runnable.
|
||||
timeout-minutes: 5
|
||||
run: python3 scripts/check_examples.py
|
||||
|
||||
- name: Check critical return-value diagnostics
|
||||
timeout-minutes: 5
|
||||
run: python3 scripts/check_nodiscard.py
|
||||
@@ -174,7 +182,10 @@ jobs:
|
||||
|
||||
- name: Test CI and repository scripts
|
||||
timeout-minutes: 10
|
||||
run: python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
# -X utf8 so the runner's own output never follows the host ANSI code
|
||||
# page; on Linux CI this is already the default. Test modules are
|
||||
# imported by the runner, so they cannot guard their own stdio.
|
||||
run: python3 -X utf8 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
|
||||
profile-matrix:
|
||||
name: Profile ${{ matrix.profile }}
|
||||
@@ -277,9 +288,9 @@ jobs:
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.windows == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
|
||||
runs-on: windows-latest
|
||||
# This lane builds and tests both Vulkan and native Dawn configurations.
|
||||
# A cold Windows runner needs more than 90 minutes for the two independent
|
||||
# unit_test builds; keep the per-step limits below as the hang guards.
|
||||
timeout-minutes: 180
|
||||
# Release run 35436382201 exhausted the former 180-minute job budget while
|
||||
# the bounded native Dawn build step was still making progress.
|
||||
timeout-minutes: 300
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64
|
||||
@@ -359,10 +370,9 @@ jobs:
|
||||
|
||||
- name: Build win32 debug
|
||||
# A PR that changes the third-party patch/cache key must do a full cold
|
||||
# dependency plus engine/test build. Recent windows-2025 runners can
|
||||
# exceed 40 minutes without any compiler failure, so retain a bounded
|
||||
# hang guard while allowing the cold build to finish.
|
||||
timeout-minutes: 60
|
||||
# dependency plus engine/test build. Release run 35419568563 was still
|
||||
# compiling normally when the former 60-minute guard terminated it.
|
||||
timeout-minutes: 100
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -520,7 +530,7 @@ jobs:
|
||||
# A cold Release build took 55 minutes and the process-isolated 3510-case
|
||||
# suite reached 3060/3510 at the former 30-minute test guard. Preserve
|
||||
# bounded hang detection while budgeting setup, build, and the full suite.
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64-release
|
||||
@@ -566,7 +576,9 @@ jobs:
|
||||
run: bash .github/scripts/setup-software-vulkan.sh
|
||||
|
||||
- name: Build win32 release
|
||||
timeout-minutes: 90
|
||||
# Cold release builds reached the former 90- and 120-minute guards
|
||||
# without a compiler failure in runs 35419568563 and 35436382201.
|
||||
timeout-minutes: 160
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
@@ -589,10 +601,9 @@ jobs:
|
||||
needs: [source-quality, change-scope]
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.android == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
|
||||
runs-on: windows-latest
|
||||
# A cold NDK build can consume almost the entire 45-minute build-step budget
|
||||
# before Gradle packages the finished native library. Keep the step timeout
|
||||
# below as the hang guard while leaving enough job budget for setup/cleanup.
|
||||
timeout-minutes: 90
|
||||
# Release run 35436382201 reached 1363/1408 with no compiler failures at
|
||||
# the former 60-minute build guard. Leave setup/cleanup headroom as well.
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-debug
|
||||
@@ -620,6 +631,10 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Start C/C++ compilation cache
|
||||
timeout-minutes: 5
|
||||
run: sccache --start-server
|
||||
|
||||
- name: Setup Java 17
|
||||
timeout-minutes: 10
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
@@ -650,7 +665,7 @@ jobs:
|
||||
test -f "$ANDROID_NDK/build/cmake/android.toolchain.cmake"
|
||||
|
||||
- name: Build android debug
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 85
|
||||
run: |
|
||||
test -f "$ANDROID_NDK/build/cmake/android.toolchain.cmake"
|
||||
make build/android-debug \
|
||||
@@ -670,7 +685,9 @@ jobs:
|
||||
needs: [source-quality, change-scope]
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.android == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
# The release build step alone may take up to 90 minutes on a cold runner.
|
||||
# Leave room for toolchain setup and cache reporting at the job level.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-release
|
||||
@@ -698,6 +715,10 @@ jobs:
|
||||
with:
|
||||
version: "v0.16.0"
|
||||
|
||||
- name: Start C/C++ compilation cache
|
||||
timeout-minutes: 5
|
||||
run: sccache --start-server
|
||||
|
||||
- name: Setup Java 17
|
||||
timeout-minutes: 10
|
||||
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
|
||||
@@ -746,7 +767,9 @@ jobs:
|
||||
needs: [source-quality, change-scope]
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.macos == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 120
|
||||
# The debug unit suite exceeded its former 30-minute step guard in release
|
||||
# run 35566503055. Budget the suite and the separate Dawn lane together.
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-debug
|
||||
@@ -785,7 +808,9 @@ jobs:
|
||||
run: bash .github/scripts/resolve-vulkan-macos.sh "${{ runner.temp }}/VulkanSDK"
|
||||
|
||||
- name: Build macosx debug
|
||||
timeout-minutes: 40
|
||||
# A cold build reached 96% of unit_test before the former 40-minute
|
||||
# guard terminated release run 35419568563.
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: >-
|
||||
@@ -793,7 +818,7 @@ jobs:
|
||||
CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE"
|
||||
|
||||
- name: Run macosx unit tests
|
||||
timeout-minutes: 30
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
ALSOFT_DRIVERS: "null"
|
||||
run: make test/macosx-debug
|
||||
@@ -851,7 +876,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Build native Dawn Metal lane
|
||||
timeout-minutes: 40
|
||||
# A cold Dawn/Tint build was still progressing with zero compiler
|
||||
# failures at the former 40-minute guard in release run 35436382201.
|
||||
timeout-minutes: 75
|
||||
run: cmake --build build/webgpu-macos --target unit_test -j "$(sysctl -n hw.ncpu)"
|
||||
|
||||
- name: Report C/C++ compilation cache
|
||||
@@ -873,7 +900,9 @@ jobs:
|
||||
needs: [source-quality, change-scope]
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.macos == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 60
|
||||
# The 75-minute build and 30-minute test guards must both fit inside the
|
||||
# job budget, together with checkout and toolchain setup.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-release
|
||||
@@ -1228,10 +1257,14 @@ jobs:
|
||||
ctest --test-dir build/linux-debug --output-on-failure -j 2 \
|
||||
-R '^bundle/(graphics_font|TextureSampler|gpgpu|tensor|virtualgeometry_gpu|graphics_headless)\.cpp$'
|
||||
|
||||
- name: Smoke all examples (headless)
|
||||
- name: Smoke all examples (headless, >=2s alive each)
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
ALSOFT_DRIVERS: "null"
|
||||
# Contract for examples/README.md "示例规范": every runnable example
|
||||
# must still be running MIN_RUN_SECONDS after launch, with no error
|
||||
# markers in its log; dying early or exiting non-zero fails the job.
|
||||
MIN_RUN_SECONDS: "2"
|
||||
run: |
|
||||
xvfb-run -a -s "-screen 0 1280x800x24" bash scripts/smoke_examples.sh
|
||||
|
||||
@@ -1247,7 +1280,9 @@ jobs:
|
||||
needs: [source-quality, change-scope]
|
||||
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.linux == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 60
|
||||
# The 75-minute build and 30-minute test guards must both fit inside the
|
||||
# job budget, together with checkout and toolchain setup.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-release
|
||||
@@ -1443,7 +1478,9 @@ jobs:
|
||||
needs: [source-quality]
|
||||
if: ${{ needs.source-quality.result == 'success' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 90
|
||||
# The build plus half of the coverage test suite consumed the former
|
||||
# 90-minute job budget in release run 35436382201.
|
||||
timeout-minutes: 150
|
||||
env:
|
||||
SCCACHE_GHA_ENABLED: "true"
|
||||
SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-coverage
|
||||
|
||||
@@ -322,6 +322,12 @@ jobs:
|
||||
ANDROID_JOBS="$JOBS"
|
||||
fi
|
||||
|
||||
- name: Test ios SDK artifact usability
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: bash .github/scripts/test-sdk.sh dist/eve-sdk/ios ios "${TAG#v}"
|
||||
|
||||
- name: Package SDK zip
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
@@ -651,6 +657,14 @@ jobs:
|
||||
export VK_ICD_FILENAMES="$(ls /usr/share/vulkan/icd.d/lvp_icd*.json 2>/dev/null | head -1)"
|
||||
xvfb-run -a bash .github/scripts/consumer-test.sh "$HOST" "$TAG" "$RUNNER_TEMP/consumer"
|
||||
|
||||
- name: Upload android APK
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: android-apk-${{ env.HOST }}
|
||||
path: ${{ runner.temp }}/consumer/eve-android-${{ env.HOST }}.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload cross win32 package (produced on linux)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -696,6 +710,14 @@ jobs:
|
||||
timeout-minutes: 90
|
||||
run: bash .github/scripts/consumer-test.sh "$HOST" "$TAG" "$RUNNER_TEMP/consumer"
|
||||
|
||||
- name: Upload android APK
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: android-apk-${{ env.HOST }}
|
||||
path: ${{ runner.temp }}/consumer/eve-android-${{ env.HOST }}.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload cross win32 package (produced on macosx)
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -843,57 +865,63 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- Android emulator run (best-effort first; promote to hard gate later) --
|
||||
# The official SDK ships arm64-v8a only, so the APK needs an arm64 emulator:
|
||||
# macOS (arm64) runners run these natively.
|
||||
# ---- Android emulator run (release hard gate) ------------------------------
|
||||
# Every desktop consumer builds an ARM64-only APK from the official Android
|
||||
# SDK. API 35's x86_64 image can execute those APKs through ARM translation,
|
||||
# while GitHub's Linux runner supplies KVM for a reliable emulator boot.
|
||||
run-android:
|
||||
name: Run APK on arm64 emulator (best-effort)
|
||||
name: Run all ARM64 consumer APKs on API 35 emulator
|
||||
needs: [consumer-win32, consumer-linux, consumer-macosx]
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 45
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Download android APK
|
||||
- name: Checkout
|
||||
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Download all consumer Android APKs
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: android-apk-linux
|
||||
pattern: android-apk-*
|
||||
path: apk
|
||||
merge-multiple: true
|
||||
|
||||
- name: Boot arm64 emulator, install and launch the game
|
||||
timeout-minutes: 40
|
||||
- name: Enable KVM access
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Boot API 35 emulator, install and launch every ARM64 APK
|
||||
timeout-minutes: 115
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
|
||||
with:
|
||||
api-level: 30
|
||||
arch: arm64-v8a
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
target: google_apis
|
||||
emulator-boot-timeout: 1800
|
||||
emulator-options: >-
|
||||
-no-window
|
||||
-gpu swiftshader
|
||||
-no-snapshot
|
||||
-noaudio
|
||||
-no-boot-anim
|
||||
-accel on
|
||||
disable-linux-hw-accel: false
|
||||
disable-animations: true
|
||||
script: |
|
||||
adb logcat -c
|
||||
adb install -r apk/*.apk
|
||||
adb shell am start -n com.evengine.example/.EVEngineActivity
|
||||
ok=0
|
||||
for i in $(seq 1 90); do
|
||||
if adb logcat -d | grep -q EVE_CI_GAME_OK; then ok=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$ok" -eq 1 ]; then
|
||||
echo "OK: APK printed EVE_CI_GAME_OK on the emulator"
|
||||
else
|
||||
echo "WARN: game marker not seen (logcat tail):"
|
||||
adb logcat -d | tail -n 60 || true
|
||||
exit 1
|
||||
fi
|
||||
script: bash .github/scripts/run-consumer-apks.sh apk
|
||||
|
||||
# ---- iOS: engine runs a game on the simulator (best-effort) ---------------
|
||||
# ---- iOS: engine runs a game on the simulator (release hard gate) ----------
|
||||
# The released ios SDK is a device app; the simulator cannot execute device
|
||||
# slices, so this builds the game app for iphonesimulator from the release
|
||||
# source and boots it. Soft gate: report, never block the release.
|
||||
# source and boots it. A failure blocks formal release promotion.
|
||||
ios-sim:
|
||||
name: iOS simulator run (best-effort)
|
||||
name: iOS simulator run
|
||||
needs: publish
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 120
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -56,8 +56,10 @@ export ALSOFT_DRIVERS=null # OpenAL Soft nul
|
||||
`docs/dev/superpowers/specs/2026-08-18-test-suite-optimization.md`.
|
||||
- Run an example (long-lived GUI loop — use `timeout` for a smoke run):
|
||||
`cd examples/basic && VK_ICD_FILENAMES=... ALSOFT_DRIVERS=... XDG_RUNTIME_DIR=... xvfb-run -a ../../build/linux-debug/src/engine/eve run`
|
||||
- `scripts/smoke_examples.sh <name>` launches an example headless and greps for error
|
||||
markers (PASS/FAIL) — a good quick check under `xvfb-run`.
|
||||
- `scripts/smoke_examples.sh <name>` launches an example headless, requires it to stay
|
||||
alive for `MIN_RUN_SECONDS` (default 2s) and greps for error markers (PASS/FAIL) — a
|
||||
good quick check under `xvfb-run`. `python3 scripts/check_examples.py` (or
|
||||
`make check/examples`) validates the `examples/` layout half of the same contract.
|
||||
- The benign `[ALSOFT] Failed to set real-time priority` warning is expected on this VM.
|
||||
|
||||
### Capturing a rendered frame (visual artifact)
|
||||
@@ -269,6 +271,32 @@ model behind them.
|
||||
- **Tests stay per-module.** New tests go into their own file under `test/`
|
||||
(zeroerr cases; each case is process-isolated via CTest). Do not grow a
|
||||
shared test main.
|
||||
- **Examples are a tested surface.** A new `examples/<name>/` must be runnable
|
||||
(`main.nut` + a `config.nut` that assigns the engine `config` table), or be
|
||||
listed with a reason in `scripts/check_examples.py`'s `NON_RUNNABLE_EXAMPLES`;
|
||||
it must own a `README.md` and a row in `examples/README.md`. `make check/examples`
|
||||
(source-quality) enforces that layout, and `scripts/smoke_examples.sh` launches
|
||||
every runnable example in CI for at least `MIN_RUN_SECONDS` (2s) — dying early,
|
||||
exiting non-zero, or printing an error marker fails the job. Run
|
||||
`MIN_RUN_SECONDS=2 bash scripts/smoke_examples.sh <name>` before claiming an
|
||||
example works.
|
||||
- **Repository scripts are UTF-8 on every host.** CI runs them on Linux, where
|
||||
stdout/stderr are UTF-8; on Windows a *redirected* stream falls back to the
|
||||
ANSI code page (cp936/GBK on a Chinese Windows), so printing a character that
|
||||
code page cannot encode (`↔`, `²`, `³`, `⇒`, `•`, emoji — all common in this
|
||||
repository) raises `UnicodeEncodeError` and turns a checker into a traceback.
|
||||
The mirror image is input: `Path.read_text()` without `encoding=` decodes
|
||||
UTF-8 repository text as cp936 and raises `UnicodeDecodeError`.
|
||||
- Every `scripts/*.py` program (a module with an `if __name__ == "__main__":`
|
||||
block) calls `utf8_stdio.enable_utf8_stdio()` as the first statement of that
|
||||
block — never at import time, so importing a script module stays free of
|
||||
side effects.
|
||||
- Text file I/O always passes `encoding="utf-8"` explicitly.
|
||||
- Decode a child process with the encoding that child actually writes: `git`
|
||||
output is UTF-8, so its call sites pass `encoding="utf-8"`.
|
||||
- `scripts/tests/test_utf8_stdio.py` enforces all of the above and runs in the
|
||||
CI script-test step; `python3 -X utf8 -m unittest discover -s scripts/tests
|
||||
-p "test_*.py"` is the local equivalent.
|
||||
- **PR granularity.** An interface change ships as one PR that updates the
|
||||
interface, every backend and every consumer — no intermediate commits that
|
||||
break CI. Use `codex/` branch prefixes for agent work.
|
||||
|
||||
@@ -91,7 +91,7 @@ message(STATUS "Compiler cache: ${_eve_compiler_cache_status}")
|
||||
|
||||
set(EVENGINE_MAJOR_VERSION "0")
|
||||
set(EVENGINE_MINOR_VERSION "5")
|
||||
set(EVENGINE_PATCH_VERSION "1")
|
||||
set(EVENGINE_PATCH_VERSION "2")
|
||||
set(EVENGINE_DEV_VERSION "")
|
||||
|
||||
set(EVENGINE_VERSION "${EVENGINE_MAJOR_VERSION}.${EVENGINE_MINOR_VERSION}.${EVENGINE_PATCH_VERSION}${EVENGINE_DEV_VERSION}")
|
||||
|
||||
@@ -144,7 +144,7 @@ GAME ?=
|
||||
reinstall/third-party/ios reinstall/third-party/ios-debug \
|
||||
link-compile-commands download-classic-scenes download-skinned-character \
|
||||
check/test-manifest check/module-layers check/bindings check/nodiscard check/quality-metadata \
|
||||
check/profile-matrix check/architecture-contracts check/quality \
|
||||
check/profile-matrix check/architecture-contracts check/quality check/examples \
|
||||
profile profile/configure profile/build profile/smoke profile/dry-run \
|
||||
ensure-built/win32 ensure-built/win32-debug ensure-built/linux ensure-built/linux-debug \
|
||||
ensure-built/macosx ensure-built/macosx-debug \
|
||||
@@ -173,6 +173,13 @@ show-targets:
|
||||
check/test-manifest:
|
||||
python3 scripts/check_test_manifest.py
|
||||
|
||||
# Verify the examples/ layout contract: runnable vs reviewed non-runnable
|
||||
# directories, config.nut assigning the engine config table, README.md presence
|
||||
# and registration in examples/README.md. Source-only; the runtime half of the
|
||||
# contract is scripts/smoke_examples.sh (MIN_RUN_SECONDS).
|
||||
check/examples:
|
||||
python3 scripts/check_examples.py
|
||||
|
||||
# Verify module includes never climb above the declared manifest LAYER.
|
||||
check/module-layers:
|
||||
python3 scripts/module_depgraph.py --check-layers
|
||||
@@ -196,9 +203,11 @@ check/nodiscard:
|
||||
|
||||
# Validate the ten top-level architecture contracts and lint changed C/C++
|
||||
# lines. The source-only gate never configures or builds the engine.
|
||||
# The unit test runs with -X utf8: its own stdout/stderr would otherwise follow
|
||||
# the host ANSI code page (cp936 on a Chinese Windows) and mangle test output.
|
||||
check/architecture-contracts:
|
||||
python3 scripts/check_architecture_contracts.py $(if $(ARCHITECTURE_BASE),--base "$(ARCHITECTURE_BASE)")
|
||||
python3 -m unittest scripts.tests.test_architecture_contracts -v
|
||||
python3 -X utf8 -m unittest scripts.tests.test_architecture_contracts -v
|
||||
|
||||
# Focused CPU tests for format round trips and live tile editing; no GPU host.
|
||||
AGENT_TEST_BUILD ?= build/agent
|
||||
@@ -772,7 +781,14 @@ sync/android-libs:
|
||||
if [ ! -d "$$TP_DIR/lib" ]; then TP_DIR=build/third-party-binary/android; fi; \
|
||||
cp -f "$$TP_DIR/lib/libSDL2.so" $(JNI_LIBS)/; \
|
||||
if [ -f "$$TP_DIR/lib/libhidapi.so" ]; then cp -f "$$TP_DIR/lib/libhidapi.so" $(JNI_LIBS)/; fi
|
||||
cp -f "$(ANDROID_NDK)/toolchains/llvm/prebuilt/"*"/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so" $(JNI_LIBS)/
|
||||
@case "$(ANDROID_ABI)" in \
|
||||
arm64-v8a) ndk_triple=aarch64-linux-android ;; \
|
||||
armeabi-v7a) ndk_triple=arm-linux-androideabi ;; \
|
||||
x86_64) ndk_triple=x86_64-linux-android ;; \
|
||||
x86) ndk_triple=i686-linux-android ;; \
|
||||
*) echo "Unsupported Android ABI: $(ANDROID_ABI)" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
cp -f "$(ANDROID_NDK)/toolchains/llvm/prebuilt/"*"/sysroot/usr/lib/$$ndk_triple/libc++_shared.so" $(JNI_LIBS)/
|
||||
@echo "Synced native libs -> $(JNI_LIBS)"
|
||||
ls -la $(JNI_LIBS)
|
||||
|
||||
|
||||
@@ -228,16 +228,39 @@ if(BUILD_PLATFORM STREQUAL "android")
|
||||
if(NOT EVENGINE_THIRD_PARTY_BINARY_DIR AND CMAKE_BUILD_TYPE STREQUAL "Debug")
|
||||
set(_eve_tp_lib "${CMAKE_SOURCE_DIR}/build/third-party-binary/${BUILD_PLATFORM}-debug")
|
||||
endif()
|
||||
if(EXISTS "${_eve_tp_lib}/lib/libSDL2.so")
|
||||
install(FILES "${_eve_tp_lib}/lib/libSDL2.so" DESTINATION lib)
|
||||
endif()
|
||||
if(EXISTS "${_eve_tp_lib}/lib/libhidapi.so")
|
||||
install(FILES "${_eve_tp_lib}/lib/libhidapi.so" DESTINATION lib)
|
||||
endif()
|
||||
# libc++_shared.so from NDK (best-effort; packaging docs note NDK fallback)
|
||||
# `deps` is built after the first configure on clean CI runners, so these
|
||||
# files do not exist while install rules are being generated. Resolve them
|
||||
# at install time and fail instead of publishing an unusable Android SDK.
|
||||
install(CODE "
|
||||
set(_eve_android_tp_lib \"${_eve_tp_lib}/lib\")
|
||||
foreach(_eve_required_lib IN ITEMS libSDL2.so)
|
||||
if(NOT EXISTS \"\${_eve_android_tp_lib}/\${_eve_required_lib}\")
|
||||
message(FATAL_ERROR
|
||||
\"Android SDK runtime library missing: \${_eve_android_tp_lib}/\${_eve_required_lib}\")
|
||||
endif()
|
||||
file(INSTALL DESTINATION \"\${CMAKE_INSTALL_PREFIX}/lib\" TYPE FILE
|
||||
FILES \"\${_eve_android_tp_lib}/\${_eve_required_lib}\")
|
||||
endforeach()
|
||||
if(EXISTS \"\${_eve_android_tp_lib}/libhidapi.so\")
|
||||
file(INSTALL DESTINATION \"\${CMAKE_INSTALL_PREFIX}/lib\" TYPE FILE
|
||||
FILES \"\${_eve_android_tp_lib}/libhidapi.so\")
|
||||
endif()
|
||||
")
|
||||
# libc++_shared.so from the NDK must match the SDK's configured ABI.
|
||||
if(DEFINED ANDROID_NDK AND EXISTS "${ANDROID_NDK}")
|
||||
if(ANDROID_ABI STREQUAL "arm64-v8a")
|
||||
set(_eve_android_ndk_triple "aarch64-linux-android")
|
||||
elseif(ANDROID_ABI STREQUAL "armeabi-v7a")
|
||||
set(_eve_android_ndk_triple "arm-linux-androideabi")
|
||||
elseif(ANDROID_ABI STREQUAL "x86_64")
|
||||
set(_eve_android_ndk_triple "x86_64-linux-android")
|
||||
elseif(ANDROID_ABI STREQUAL "x86")
|
||||
set(_eve_android_ndk_triple "i686-linux-android")
|
||||
else()
|
||||
message(FATAL_ERROR "Unsupported Android ABI for libc++_shared.so: ${ANDROID_ABI}")
|
||||
endif()
|
||||
file(GLOB _eve_cxx_shared
|
||||
"${ANDROID_NDK}/toolchains/llvm/prebuilt/*/sysroot/usr/lib/aarch64-linux-android/libc++_shared.so")
|
||||
"${ANDROID_NDK}/toolchains/llvm/prebuilt/*/sysroot/usr/lib/${_eve_android_ndk_triple}/libc++_shared.so")
|
||||
if(_eve_cxx_shared)
|
||||
list(GET _eve_cxx_shared 0 _eve_cxx_one)
|
||||
install(FILES "${_eve_cxx_one}" DESTINATION lib)
|
||||
|
||||
@@ -100,7 +100,7 @@ eve_declare_module(NAME pixelworld_streaming LAYER 1
|
||||
DEPS asset pixelworld
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME rpg LIB EVRPG LAYER 1 SCRIPT RPG
|
||||
DEPS action attributes decision definitions effects inventory settlement)
|
||||
DEPS action attributes decision definitions dnut_interpreter effects inventory settlement)
|
||||
# L0 -- foundation (continued)
|
||||
eve_declare_module(NAME inventory LAYER 0 SCRIPT Inventory)
|
||||
eve_declare_module(NAME economy LAYER 0 SCRIPT Economy SLOT economy
|
||||
@@ -145,8 +145,22 @@ eve_declare_module(NAME npc_ai LAYER 1
|
||||
eve_declare_module(NAME action LIB EVAction LAYER 1
|
||||
DEPS decision sensing tags transaction
|
||||
GROUP minimal 2d 3d web)
|
||||
# `.dnut` authored-sequence language. Owns the shared lexer, the registry-driven
|
||||
# compiler, the compiled sequence model and the cross-frame interpreter. Domain
|
||||
# vocabularies (dialogue lines and pools, RPG story steps) are registered by the
|
||||
# modules that own them; this core never interprets a domain payload.
|
||||
eve_declare_module(NAME dnut_interpreter LIB EVDnutInterpreter LAYER 1)
|
||||
# L2 -- combat resolution and optional adapters consuming the action protocol
|
||||
# Optional persistent GameEventLog adapter for action timeline events.
|
||||
eve_declare_module(NAME action_game_event DIR action/game_event LAYER 2
|
||||
DEPS action game_event
|
||||
GROUP minimal 2d 3d web)
|
||||
# Optional semantic combo-window projection consumed by player, AI, and script input adapters.
|
||||
eve_declare_module(NAME action_input DIR action/input LAYER 2
|
||||
DEPS action
|
||||
GROUP minimal 2d 3d web)
|
||||
# L2 -- combat resolution consuming the action protocol
|
||||
eve_declare_module(NAME combat LIB EVCombat LAYER 2
|
||||
eve_declare_module(NAME combat LIB EVCombat LAYER 2 SCRIPT Combat SLOT combat
|
||||
DEPS action attributes tags
|
||||
GROUP minimal 2d 3d web)
|
||||
# Shared fixed-step/backend contract extracted from the physics host so
|
||||
|
||||
@@ -54,7 +54,7 @@ eve_declare_module(NAME touch LAYER 2 SCRIPT Touch SLOT touch
|
||||
THIRDPARTY sdl2
|
||||
GROUP minimal 2d 3d web)
|
||||
eve_declare_module(NAME audio LAYER 2 SCRIPT Audio SLOT audio
|
||||
DEPS platform_event sound
|
||||
DEPS action platform_event sound
|
||||
OPTIONAL_DEPS scene
|
||||
THIRDPARTY openal
|
||||
GROUP 2d 3d)
|
||||
|
||||
@@ -54,7 +54,7 @@ eve_declare_module(NAME level_editing LAYER 5
|
||||
DEPS editing
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME particles LAYER 5 SCRIPT Particles SLOT particles
|
||||
DEPS animation data filesystem graphics ik
|
||||
DEPS action animation data filesystem graphics ik
|
||||
THIRDPARTY poco
|
||||
GROUP 2d 3d)
|
||||
# Surface fluid simulation: particles constrained to mesh SDFs (flow down
|
||||
@@ -243,13 +243,20 @@ eve_declare_module(NAME pixelworld_editor LAYER 6 SCRIPT PixelWorldEditorModule
|
||||
GROUP 2d 3d web)
|
||||
# Runtime bridge from capability-selected packages into executable PointGraphs.
|
||||
eve_declare_module(NAME asset_procgen LAYER 6
|
||||
DEPS asset procgen
|
||||
DEPS asset asset_import data procgen
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME procgen_physics LAYER 6 SCRIPT ProcgenPhysics SLOT procgenPhysics
|
||||
DEPS physics procgen
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME procgen_animation DIR procgen/animation LAYER 6
|
||||
SCRIPT ProcgenAnimation SLOT procgenAnimation
|
||||
DEPS animation procgen
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME snow LAYER 6 SCRIPT Snow SLOT snow
|
||||
DEPS graphics procgen
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME sceneloader LIB EVSceneLoader LAYER 6 SCRIPT SceneLoader
|
||||
DEPS animation data filesystem graphics image model3d scene thread
|
||||
DEPS action animation data filesystem graphics image model3d scene thread
|
||||
THIRDPARTY assimp
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME dialogue LAYER 6
|
||||
|
||||
@@ -26,7 +26,7 @@ eve_declare_module(NAME pixelworld_graphics LAYER 4 SCRIPT PixelWorldGraphics SL
|
||||
GROUP 2d 3d web)
|
||||
|
||||
eve_declare_module(NAME camera LAYER 4 SCRIPT Camera SLOT camera
|
||||
DEPS platform_event graphics scene
|
||||
DEPS action platform_event graphics scene
|
||||
GROUP minimal 2d 3d web)
|
||||
eve_declare_module(NAME gpgpu LAYER 4 SCRIPT Gpgpu SLOT gpgpu
|
||||
DEPS data filesystem graphics
|
||||
@@ -47,6 +47,12 @@ eve_declare_module(NAME map LAYER 4 SCRIPT Map SLOT map
|
||||
DEPS data filesystem graphics grid
|
||||
THIRDPARTY poco
|
||||
GROUP 2d 3d)
|
||||
# Interactive 3D hex map: an editable pointy-top cell grid plus per-chunk mesh
|
||||
# generation (ground fans, blend strips, terraces, cliffs, water, rivers, roads,
|
||||
# city walls, decorations and the fog overlay).
|
||||
eve_declare_module(NAME hexmap LAYER 4 SCRIPT HexMap SLOT hexmap
|
||||
DEPS graphics
|
||||
GROUP 3d)
|
||||
eve_declare_module(NAME map_editing LAYER 4
|
||||
DEPS editing
|
||||
OPTIONAL_DEPS map
|
||||
@@ -73,6 +79,15 @@ eve_declare_module(NAME physics_rope DIR physics/rope LIB EVPhysicsRope LAYER 5
|
||||
SCRIPT Rope SLOT rope
|
||||
DEPS physics schema
|
||||
GROUP 3d web)
|
||||
# Optional Action adapter for generation-safe 3D body-pair collision windows.
|
||||
eve_declare_module(NAME physics_action DIR physics/action LAYER 5
|
||||
DEPS action physics
|
||||
GROUP 3d web)
|
||||
# Optional map-backed steering provider. Keeping this bridge above both owners
|
||||
# lets headless/minimal combat builds omit the rendering-heavy map closure.
|
||||
eve_declare_module(NAME combat_navigation DIR combat/navigation LAYER 5
|
||||
DEPS combat map
|
||||
GROUP 2d 3d)
|
||||
eve_declare_module(NAME pixelworld_physics LAYER 5 SCRIPT PixelWorldPhysics SLOT pixelworldPhysics
|
||||
DEPS pixelworld physics
|
||||
GROUP 2d)
|
||||
|
||||
@@ -222,6 +222,12 @@ function(check_third_party_project name repo)
|
||||
-DANDROID_NDK=${ANDROID_NDK}
|
||||
-DCMAKE_SYSTEM_NAME=Android
|
||||
)
|
||||
# NDK 27 exposes arm_neon.h even for x86 targets, which makes this
|
||||
# OpenAL Soft version mis-detect NEON and compile ARM intrinsics with
|
||||
# an x86 compiler. Preserve NEON for the release arm64 build.
|
||||
if(ANDROID_ABI MATCHES "^x86(_64)?$")
|
||||
list(APPEND _eve_tp_cmake_args -DALSOFT_CPUEXT_NEON=OFF)
|
||||
endif()
|
||||
if(CMAKE_MAKE_PROGRAM)
|
||||
list(APPEND _eve_tp_cmake_args -DCMAKE_MAKE_PROGRAM=${CMAKE_MAKE_PROGRAM})
|
||||
endif()
|
||||
@@ -409,7 +415,14 @@ function(check_third_party_project name repo)
|
||||
)
|
||||
endif()
|
||||
target_include_directories(eve_engine_includes INTERFACE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include)
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include
|
||||
# SDL2 and FreeType install headers below their own include roots.
|
||||
# These directories do not exist yet during a clean configure because
|
||||
# the ExternalProject install happens in the later `deps` build. They
|
||||
# must still be registered now; testing IS_DIRECTORY here makes clean
|
||||
# iOS/simulator builds miss <SDL.h> or <ft2build.h>.
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include/SDL2
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include/freetype2)
|
||||
eve_external_include(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include)
|
||||
target_link_directories(eve_engine_includes INTERFACE
|
||||
|
||||
@@ -14,7 +14,7 @@ endif()
|
||||
if(NOT DEFINED EVENGINE_MAJOR_VERSION)
|
||||
set(EVENGINE_MAJOR_VERSION "0")
|
||||
set(EVENGINE_MINOR_VERSION "5")
|
||||
set(EVENGINE_PATCH_VERSION "1")
|
||||
set(EVENGINE_PATCH_VERSION "2")
|
||||
endif()
|
||||
|
||||
get_filename_component(DOC_SOURCE_ROOT "${DOC_SOURCE_ROOT}" ABSOLUTE)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# TileWorldCreator 4 核心功能移植到 EVEngine
|
||||
|
||||
## 范围
|
||||
|
||||
本移植以 `TileWorldCreator 4 v4.3.5.unitypackage` 中的运行时算法、图组合、网格构建和配置语义为
|
||||
行为参考,采用 EVEngine 原生 `Grid2D`、`PointSet`、`PointGraph`、`MeshBuild`、Result 与模块句柄。
|
||||
“核心功能完成”不包括 Unity Inspector/EditorWindow 外观、Unity Prefab/ScriptableObject 二进制兼容、
|
||||
Unity Renderer/Collider 组件或示例美术资产;这些是宿主编辑器与资产格式,不是生成算法本身。
|
||||
|
||||
## 功能对应
|
||||
|
||||
- Dot Grid → `generate.dot_grid`
|
||||
- Cellular Automata → `generate.cellular`
|
||||
- Pathfinding → `grid.path`
|
||||
- BSP Dungeon → `generate.registry` + `algorithm=dungeon.bsp`;完整 Roguelike 地牢使用
|
||||
`algorithm=level.roguelike`,不在 GridGraph 内维护第二套地牢算法
|
||||
- Random Walk Dungeon → `generate.random_walk`
|
||||
- Maze → `generate.maze`
|
||||
- Height Texture → `Grid2D.detail` 输入与 `select.detail_range`
|
||||
- Random Noise → `generate.random_noise`
|
||||
- Shapes → `generate.shape`
|
||||
- Checkerboard → `generate.checkerboard`
|
||||
- Poisson Disc Sampling → `generate.poisson`
|
||||
- Add / Boolean / Subtract → `grid.union`、`grid.intersect`、`grid.subtract`
|
||||
- Smooth / Expand / Shrink / Invert → 同名 `grid.*` 节点
|
||||
- Select / Select by neighbour / Select by rule → `select.random`、`select.neighbors`、`select.rule`
|
||||
- Select islands / Find position on islands → `select.islands`、`select.island_centers`
|
||||
- Push to paint positions → 图输出的拥有型 `Grid2D`;由调用方显式绑定为下游输入,不使用隐式全局画布
|
||||
- Tile preset / neighbour rules → `grid.autotile` 把八邻域 mask 写入 detail;
|
||||
`resolveTilePreset` 按 4.3.5 原表精确解析 14 类标准网格瓦片、旋转、X 镜像和 9 位配置
|
||||
- Object build layer → `ObjectBuildLayer` 把 PointGraph 输出转换为带 `asset`、父子关系和完整变换的
|
||||
`PointSet`,支持确定性加权选择、随机变换、朝向层、place-on-top 高度属性和子对象规则
|
||||
- Persistent build configuration → `BuildLayerStack` 按插入顺序执行启用的 Tiles/Objects 层,使用
|
||||
`EVPCG_BUILD_LAYERS 1` 与嵌入式 `EVPCG_OBJECT_LAYER 1` 格式原子回读,失败不发布部分 artifacts
|
||||
- Incremental build → `IncrementalBuildExecutor` 按固定格子簇缓存 BuildLayerStack artifacts;哈希包含层定义、
|
||||
格子一圈 halo、完整点字段和强类型属性,输出有序 upsert/removal delta,任一簇失败则旧缓存原样保留
|
||||
- Tiles build layer / mesh combiner → `mesh.grid_tiles`、`mesh.merge`、`mesh.transform`
|
||||
- Grid mesh collider generator → 增量示例从每簇 `MeshBuild` 创建 `World3D` 静态三角网格碰撞体;
|
||||
更新采用先建后换,removal 同步销毁,且 physics provider 缺席时可观察地退化为纯渲染
|
||||
- Runtime manager / configuration → Procgen 模块拥有的代际句柄、图 revision、版本 1 定义格式和统一 Result
|
||||
|
||||
## 混合调用契约
|
||||
|
||||
标准路径为:
|
||||
|
||||
`GridGraph -> convert.grid_to_points -> point.subgraph(PointGraph) -> MeshGraph.point.input -> mesh.instance_points`
|
||||
|
||||
网格铺砖路径为:
|
||||
|
||||
`GridGraph -> grid.autotile -> MeshGraph.grid.input -> mesh.grid_tiles -> MeshBuild/GeneratedArtifact`
|
||||
|
||||
跨域只传递拥有型值或显式句柄。连接建立时检查端口类型;修改从节点向下游失效缓存;图实例仅允许
|
||||
所属线程访问且不可重入。Grid/Mesh 定义只持久化拓扑与标量参数,场景输入在恢复后重新绑定。
|
||||
|
||||
## 确定性与失败语义
|
||||
|
||||
随机生成器使用图参数中的命名 seed;相同版本、尺寸、参数和 seed 产生相同网格。图执行成本与访问
|
||||
格子数、输出顶点数或“源网格顶点数 × 点数量”成线性关系。无效操作、参数、类型、断开的必需输入、
|
||||
环、尺寸不匹配和损坏资产均返回结构化 Result,不使用 `bool + lastError`。
|
||||
|
||||
## 验证入口
|
||||
|
||||
- `examples/tileworld-graph-dungeon`:可直接运行的资产无关 3D 示例,覆盖注册表 Roguelike 地牢、
|
||||
`select.semantic` 分层、autotile、随机选择、
|
||||
Grid→PointGraph、版本化 BuildLayerStack、PointSet→MeshGraph 实例化,以及边界墙、导航和装饰层;
|
||||
地面与对象场景缓存直接消费 8×8 格子簇的 upsert/removal delta,不再额外执行完整 build stack。
|
||||
同一 delta 还驱动每簇静态三角网格碰撞体的替换/回收。运行时会选取可行走端点、执行
|
||||
`grid.path`,并验证 MeshBuild 上传、空对象簇、热重载和换 seed 重建。
|
||||
- `test/procgen_grid_graph.cpp`:生成器、选择器、寻路、autotile、TilePreset 精确配置、Grid→PointGraph、Grid/Point→Mesh、
|
||||
类型拒绝、环、缓存失效和两种定义格式回读。
|
||||
- `test/procgen_object_build_layer.cpp`:加权资产、确定性随机变换、朝向、place-on-top、子对象和失败路径。
|
||||
- `test/procgen_build_layer_stack.cpp`:层顺序/启用状态、混合 artifact、版本往返、未知字段和损坏配置原子拒绝。
|
||||
- `test/procgen_incremental_build.cpp`:稳定缓存、簇内与边界脏区传播、删除 delta、全局坐标网格分片、
|
||||
参数拒绝以及失败时事务回滚。
|
||||
- `test/procgen_result_contract.cpp`:Squirrel 创建拥有型 Grid2D 并直接绑定 GridGraph,随后执行
|
||||
GridGraph/MeshGraph 并取得拥有型 MeshBuild,覆盖跨图代理的生命周期与类型契约。
|
||||
- `src/modules/procgen/GridGraphAlgorithms.cpp`:独立确定性算法实现。
|
||||
@@ -226,3 +226,20 @@ Tiled 提供的相关能力:
|
||||
- 角掩码在**格子索引空间**采样(与投影无关);默认图集为 SpriteCook 式 4×4 frame 表;`useDefaultFrameTable=false` 时 `gid = firstGid + mask`
|
||||
|
||||
角位:`TL=1, TR=2, BL=4, BR=8`。mask 0 不绘制。
|
||||
|
||||
### 程序化过渡 mask
|
||||
|
||||
`generateDualGridMaskAtlas` 从四个角场自动生成 0..15 的完整 mask、平滑 coverage 与
|
||||
signed-distance field。相同配置和 seed 的结果确定,可按配置缓存;拓扑仍由逻辑 terrain grid
|
||||
唯一拥有,生成图只是可重建的显示投影。`bandAt` 可从同一 SDF 派生湿沙、浪花、积雪边缘等带状层。
|
||||
|
||||
普通 RGBA8 tile 可直接交给 `bakeDualGridTransitionAtlas(A, B, config)`,得到按 mask 行优先排列的
|
||||
4×4 atlas:frame 0 是 A,frame 15 是 B,中间 14 帧由程序化 mask 混合。这样导入任意 grass、
|
||||
sand、dirt、water tile 时无需美术提供 A→B 的 autotile atlas;运行时继续使用现有 TileLayer 与
|
||||
DualGrid frame 选择。海岸等特殊视觉只消费同一 SDF,不改变拓扑或再维护一份地形状态。
|
||||
|
||||
测试使用的最小 CC0 SBS 等距素材集位于
|
||||
`test/assets/map/sbs_isometric_overworld_flat`。从仓库根目录设置
|
||||
`EVENGINE_DUAL_GRID_FOUR_MATERIAL_PREVIEW` 为目标 BMP 路径并运行
|
||||
`map.dualGridMask.worldSpaceFourMaterialPreview`,即可生成草地、沙地、石头、水体四材质预览;
|
||||
默认直接读取随测试打包的三张图集,也可通过对应环境变量替换素材。
|
||||
@@ -1,4 +1,4 @@
|
||||
# Canonical mesh v2 and multiple UV sets
|
||||
# Canonical mesh v3, vertex colors, and multiple UV sets
|
||||
|
||||
`eve.mesh/2` preserves source UV set identifiers and all supported coordinate
|
||||
streams. There is no fixed UV0/UV1/UV2 array size. Channel descriptors and numeric
|
||||
@@ -27,6 +27,18 @@ Source strides and offsets are honored. Signed, unnormalized integer, mismatched
|
||||
count and malformed semantic names are rejected. Tangents and morph targets are
|
||||
outside this change.
|
||||
|
||||
|
||||
## Version 3 vertex colors
|
||||
|
||||
EVE mesh schema 3 and EVMESH binary version 3 add flag bit 1 and an optional linear
|
||||
RGBA float4 after all UV values in each interleaved vertex. The encoder emits v3;
|
||||
the decoder retains v1/v2 compatibility and rejects the color flag on v2. Color
|
||||
length, finite values, byte budgets, and exact payload size are validated before
|
||||
publication. A colored asset is uploaded only through uploadMeshColored; a
|
||||
provider without that capability returns Unsupported before it can silently
|
||||
drop the stream. Vulkan and WebGPU bind color at vertex location 5 and multiply
|
||||
it into the existing material/object tint, preserving locations 3/4 for skinning.
|
||||
|
||||
## Material bindings
|
||||
|
||||
Each texture's effective UV set must exist on every primitive bound to that
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# dnut 序列语言与解释器(模块 `dnut_interpreter`)
|
||||
|
||||
日期:2026-09-15
|
||||
层:**L1**
|
||||
源码:`src/modules/dnut_interpreter/`
|
||||
清单:`cmake/module_manifest/core_foundation.cmake` 的 `dnut_interpreter` 声明
|
||||
|
||||
## 1. 这个模块拥有什么
|
||||
|
||||
`.dnut` 是引擎的**作者序列语言**。本模块是它的**唯一 owner**:
|
||||
|
||||
| 事实 | 唯一 owner | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `.dnut` 词法 | `DnutLexer` | 所有方言共用;不变量是「同一文件只有一个词法器」 |
|
||||
| 编译产物模型 | `SequenceAsset` / `SequenceNode` / `SequenceRoute` | 通用图模型,节点类型是**字符串**而非闭集枚举 |
|
||||
| 步骤词表与字段表 | `StepKindRegistry` | descriptor 是字段的唯一真相源;编译器与作者 UI 都从它派生 |
|
||||
| 跨帧执行游标 | `SequenceRuntime` | 调用栈、挂起/恢复、状态捕获、执行预算护栏 |
|
||||
| `.dnut` → 资产的编译 | `DnutCompiler` | 只懂控制流关键字;步骤合法性交给注册表 |
|
||||
| 条件求值的**契约** | `SequenceConditionOutcome` | 只定义「通过/不通过 + 稳定原因」;条件系统由消费者适配 |
|
||||
|
||||
**不拥有**(明确不在本模块):任何领域语义。`line`/`pool`/`story:move`/`skill` 都是
|
||||
**注册项**,不是编译器或运行时里的 `switch` 分支。命令的实际执行、i18n、支付、
|
||||
存档格式、表现后端都在各自的模块。
|
||||
|
||||
## 2. 对外六个面
|
||||
|
||||
| 面 | 内容 | 取证 |
|
||||
| --- | --- | --- |
|
||||
| 能力入(capability in) | 无。本模块不 `cap::query` 任何接口,也不调用 `getModInst`/`ModuleManager`。消费者直接链接并在自己线程上构造注册表与运行时。 | `grep -n "cap::\|getModInst\|requireModInst" src/modules/dnut_interpreter/*` → 0 命中 |
|
||||
| 能力出(capability out) | 无 `cap::provide`。对外契约是**类型与函数**:`lexDnut` / `compileDnut` / `SequenceRuntime` / `StepKindRegistry`。 | 同上;`module_boundary_audit.py --capabilities` 中该模块无条目 |
|
||||
| ECS + Link 数据 | 无。本模块不注册 ECS 组件、不注册 Link 种类、不创建实体。 | `grep -n "SystemContract\|LinkKind\|registerLinkKind"` → 0 命中 |
|
||||
| 事件 | 唯一出口 `SequenceRuntime::setEventSink`;事件种类 `Started/NodeEntered/Blocked/Stepped/Resumed/Ended/Failed`,同步派发、无锁、不得重入。 | `SequenceRuntime.h` 的 `Event` / `EventSink` |
|
||||
| 脚本绑定 | **无 SCRIPT 声明**。脚本面由消费者提供:`rpg` 绑定 `eve.rpg.newStorySession(...)` 等。 | manifest 中该模块无 `SCRIPT` |
|
||||
| 工具协议 | 无 MCP/LSP/DAP 方法。 | — |
|
||||
|
||||
**跨模块包含**:公共头只包含 `common/Result.h`、`common/Value.h` 和本模块头。
|
||||
不包含 `decision`、`dialogue`、`rpg` —— 条件求值通过 `std::function` 契约注入。
|
||||
|
||||
## 3. 裁剪声明
|
||||
|
||||
- **L1 编译期**:`dnut_interpreter` 的源文件不参与编译时,`dialogue`/`rpg` 的 TU 会
|
||||
因找不到头而失败——它们是**硬依赖**(`DEPS`),不是可选依赖。
|
||||
- **L2 链接期**:`rpg` 的 `RpgDialect.cpp` 调用 `compileDnut`、`SequenceRuntime::start`、
|
||||
`StepKindRegistry::registerStep` 等非 inline、非虚函数,产生**未定义符号**,因此
|
||||
关掉本模块后 `EVRPG` 无法链接。这是 `R-BOUND-2` 判据表「调 B 的非虚、非 inline
|
||||
成员函数」一行。
|
||||
- **L3/L4**:本模块没有运行时可选性——它要么被编入,要么消费者无法编译。因此
|
||||
不声明 `GROUP`(与 `rpg` 一致,只随完整构建进入),也不需要 present/absent 双 profile。
|
||||
- **符号引用证据**:`rpg/RpgDialect.cpp` 对 `eve::dnut::compileDnut(...)`、
|
||||
`eve::dnut::SequenceRuntime::start(...)` 的调用即符号引用;`include` 图不作为判据
|
||||
(`R-BOUND-1`)。
|
||||
|
||||
## 4. 语言表面
|
||||
|
||||
```
|
||||
document := block*
|
||||
block := 'story' IDENT modifier* '{' statement* '}'
|
||||
| <其他方言的顶层块,直接跳过>
|
||||
modifier := 'repeatable' | 'version' '=' INT
|
||||
statement := step | ifStmt | choiceStmt | 'end'
|
||||
step := IDENT (IDENT '=' value)*
|
||||
ifStmt := 'if' condition block ['else' block]
|
||||
choiceStmt := 'choice' '{' ('option' STRING ['when' condition] block)+ '}'
|
||||
value := STRING | NUMBER | 'true' | 'false' | IDENT | '-' NUMBER
|
||||
condition := 比较 / '&&' / '||' / '!' / '(' ')'
|
||||
```
|
||||
|
||||
- `story` 之外的顶层块(`pool`、`conversation`)被**跳过**,因此一个文件可以同时
|
||||
容纳其他方言而不被本编译器拒绝。
|
||||
- 控制流关键字只有 `if` / `choice` / `call` / `wait` / `end`;它们分别下降到
|
||||
`branch` / `choice` / `call` / `wait` / `end` 节点。**步骤类型不是关键字**:
|
||||
`mark`、`skill`、`story:move` 一样由注册表声明。
|
||||
- 条件编译为 `eve::Value` 对象:`{var,op,value}`、`{all:[…]}`、`{any:[…]}`、`{not:…}`,
|
||||
`op ∈ eq|ne|gt|lt|ge|le`。**求值器由消费者注入**:语言核心不知道 `switch.x` 是什么。
|
||||
|
||||
### 4.1 编译错误恢复
|
||||
|
||||
单个语句失败时,编译器同步到下一个可能的语句起点继续,因此一次编译能报出多个
|
||||
错误(例如一个块里两处拼写错误会得到 2 条诊断)。**含错误的文档不发布资产**:
|
||||
`RpgStoryCatalogue::replaceFromDnutStrict` 在 `hasErrors()` 时保留上一份目录。
|
||||
|
||||
## 5. 步骤注册表契约
|
||||
|
||||
```cpp
|
||||
StepKindDescriptor {
|
||||
type, displayName, category,
|
||||
shape(Instant | Await),
|
||||
fields[{name, type(String|Number|Integer|Boolean|Any), required}],
|
||||
validate(node) -> Result<void> // 跨字段的领域不变量
|
||||
}
|
||||
```
|
||||
|
||||
- `Instant`:必须绑定 handler;`validate` 会拒绝「instant 但没有 handler」的声明。
|
||||
- `Await`:可以**不绑定 handler**,此时运行时挂起、宿主呈现后用
|
||||
`SequenceRuntime::advance()` 确认;绑定了 handler 的 `Await` 可以返回 `Blocked`
|
||||
并在之后用 `resumeStep(value)` 继续。
|
||||
- 字段校验是**严格**的:未声明的字段被拒绝。这是刻意的——拼错的字段名不会静默
|
||||
变成一个永远为空的属性。
|
||||
- `validate` 是跨字段规则(「`learn` 与 `forget` 恰有其一」)的落点,因此这类
|
||||
错误在**编译内容时**报出,而不是等玩家走到那一步。
|
||||
|
||||
### 5.1 `StepContext::host`
|
||||
|
||||
语言核心不理解消费者的领域对象。消费者在运行时上装一个不透明指针
|
||||
(`setHostContext`),核心原样转发给 handler。`rpg` 用它传 `RpgStoryBinding*`。
|
||||
这保持了「核心零领域依赖」,代价是一个需要文档说明的 `void*`。
|
||||
|
||||
## 6. 所有权、生命周期与线程
|
||||
|
||||
- `SequenceAsset` / `StepKindRegistry` 归调用者所有。`SequenceRuntime` 持有
|
||||
**借用**指针,调用者必须保证它们在运行时存活期内有效。
|
||||
- `RpgStorySession` 在 `begin` 时**快照整份目录**,因此热替换不会让活动会话悬垂。
|
||||
- `captureState` 存的是**稳定引用**(`asset id` + `version` + `node id` + bindings +
|
||||
locals),不是指针。`restoreState` 通过资产解析器重新解析,并在 `version` 不匹配时
|
||||
明确失败。
|
||||
- 所有方法都是**owner 线程专属**,不做同步;事件回调同步派发,且不得重入运行时。
|
||||
- 执行预算 `kExecutionBudget = 10000` 是内容写出死循环时的唯一护栏:超预算失败而不是挂死。
|
||||
|
||||
## 7. 已知债务(显式登记)
|
||||
|
||||
| 债务 | owner | 原因 | 移除条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| `dialogue` 的 `pool` / `conversation` 两套前端尚未迁到本模块 | dialogue | 迁移触及 15 个文件 + 8 个测试文件,与「新增 RPG 方言能力」混在一次交付里失败模式不可分离 | 设计文档 `docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md` §8 阶段 1 的剩余部分完成 |
|
||||
| 顶层块切分(`DnutBlockScanner`)尚未抽成显式类型,目前是编译器内的跳过逻辑 | dnut_interpreter | 只有 `story` 一种块需要真正解析,抽象尚无第二个消费者 | dialogue 迁移开始时随迁移落地 |
|
||||
| `SequenceDocument`(作者字段反射)尚未落地 | dnut_interpreter | 当前无作者 UI 消费者;字段表已由 `payloadSchema` 唯一拥有,反射可按需派生 | 编辑器接入 `.dnut` 作者视图时 |
|
||||
| `rpg::StoryEvent`(JSON 闭集)与 `.dnut` 故事并存 | rpg | 既有 JSON 路径有 4 个测试文件与线格式兼容要求;本次交付只新增能力,不做格式替换 | 设计文档 §8 阶段 2 完成时 |
|
||||
|
||||
债务没有第二真相源:`.dnut` 的语法、模型与游标只有本模块一份实现;
|
||||
`StoryEvent` 是**另一种内容格式**,不是同一事实的第二份表示。
|
||||
|
||||
## 8. 验收
|
||||
|
||||
```sh
|
||||
python3 scripts/check_module_manifest.py
|
||||
python3 scripts/module_depgraph.py --check --check-layers
|
||||
build/win32-debug/test/unit_test.exe "dnut.*"
|
||||
build/win32-debug/test/unit_test.exe "rpg.dnut.*"
|
||||
```
|
||||
@@ -116,3 +116,38 @@ procedural PBR surfaces, perturbs the boundary with world-space FBM and performs
|
||||
GGX metallic-roughness lighting evaluation. This gives stable, asset-free
|
||||
transitions and validates the end-to-end path, but it supports only two layers per
|
||||
vertex and should not become the permanent terrain data format.
|
||||
|
||||
## Update: the `hexmap` module
|
||||
|
||||
The hex map was subsequently ported from the Catlike Coding reference project as a
|
||||
dedicated module rather than another procgen recipe, because an *editable* map needs
|
||||
authoritative cell data and incremental mesh updates that a one-shot generator
|
||||
cannot provide. `docs/usr/modules/hexmap.md` is the module contract;
|
||||
`examples/hex-terrain-3d` is the interactive editor over it. It deliberately lives
|
||||
in its own directory so that `examples/hex-terrain` — the `mesh.hexterrain` proof
|
||||
described above — keeps shipping unchanged.
|
||||
|
||||
What that closes from the gap list above:
|
||||
|
||||
- Gaps 1 and 5 (no cell data, no chunking, no picking): `hexmap::HexMap` owns the
|
||||
cell grid (elevation, water, terrain, feature levels, river/road connections),
|
||||
partitions it into 5x5 chunks with dirty tracking, answers neighbour/edge
|
||||
queries, and converts a world-space ray back to a cell.
|
||||
- Gap 5's mesh side: `buildChunkSurfaceMesh` generates the terrain, water, river
|
||||
and road streams per chunk, and `HexMapModule::rebuildChunk` updates only the
|
||||
dirty surfaces in place.
|
||||
- Gap 7's "one baked mesh" half: the walkable terrain is now the chunked,
|
||||
independent surface the audit asked for.
|
||||
|
||||
What still stands:
|
||||
|
||||
- Gaps 2-4 (bindable texture arrays, independent roughness/metallic/AO maps, a
|
||||
CPU-to-GPU `PbrTextureSet`) are unchanged. The example therefore still shades the
|
||||
terrain procedurally from a packed vertex encoding, now three layers instead of
|
||||
two; `HexMapMesh.h` documents that as a compatibility bridge.
|
||||
- Gap 6 (production hydrology: basin filling, flow accumulation, confluences) is
|
||||
unchanged. The module enforces the reference's downhill/lake-outflow rule and
|
||||
draws channels, but does not solve drainage.
|
||||
- `mesh.hexterrain` and its generator are untouched and still ship the original
|
||||
proof; the module is additive.
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 实施计划:dnut 序列语言核心(L1)与 RPG 方言
|
||||
|
||||
> 上位设计:`docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md`
|
||||
> 状态:**已实施**(§1.1 语言核心、§1.2 RPG 词表、§1.3 测试与文档全部落地;
|
||||
> §5 角色移动与 §6 动画播放按增量补齐;`SequenceDocument` 与 dialogue 迁移按 §4 登记为后续增量)
|
||||
> 目标(用户原话):给 dnut 语言中的 rpg 方言添加人物的移动、动画播放、对象的选取、
|
||||
> 技能的获取、属性的修改、物品、装备获取等等功能——**完整实现**。
|
||||
|
||||
## 0. 与设计文档的偏差声明
|
||||
|
||||
设计的「阶段 1」把 dialogue 的 conversation 方言整体迁入 L1。本计划**先交付可运行的
|
||||
RPG 方言与其 L1 语言核心**,把 dialogue 迁移留作后续增量,理由:
|
||||
|
||||
1. 用户目标是**能力**(RPG 方言可表达并可执行),不是模块搬迁本身;
|
||||
2. dialogue 迁移会让一个 PR 触及 15 个文件 + 8 个测试文件,把「新能力」与
|
||||
「纯搬迁」混在一次交付里,失败模式不可分离;
|
||||
3. 语言核心(词法/资产模型/步骤注册表/运行时)一经建立,dialogue 迁移是**纯消费者改造**,
|
||||
不再需要新机制。
|
||||
|
||||
因此顺序为:**语言核心(L1)→ RPG 方言语汇 → RPG 接入与绑定 → dialogue 迁移(后续)**。
|
||||
`docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md` §8 的阶段划分
|
||||
按此顺序重读:本计划覆盖其阶段 1 的「新模块 + 语言」与阶段 2、3 的 RPG 部分。
|
||||
|
||||
## 1. 交付物清单
|
||||
|
||||
### 1.1 新模块 `src/modules/dnut_interpreter/`(L1)
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `DnutDiagnostic.h` | 与领域无关的诊断:`Severity`/`path`/`line`/`column`/`message` |
|
||||
| `DnutLexer.h/.cpp` | 统一词法器(今天 `DnutParser.cpp:101-217` 的升格),带行/列 |
|
||||
| `DnutBlockScanner.h/.cpp` | 顶层块切分(`story` / `pool` / `conversation`),供多方言共用 |
|
||||
| `SequenceAsset.h/.cpp` | 资产模型 `SequenceAsset`/`SequenceNode`/`SequenceRoute` + 图校验 |
|
||||
| `StepKindRegistry.h/.cpp` | 步骤类型注册表:descriptor + schema + validate + handler + dispatch |
|
||||
| `DnutCompiler.h/.cpp` | 注册表驱动的 `.dnut` → `SequenceAsset` 编译器(控制流核心 + 通用步骤) |
|
||||
| `SequenceRuntime.h/.cpp` | 跨帧游标运行时:分支/调用/等待/命令派发/挂起恢复/状态捕获/预算护栏 |
|
||||
| `SequenceDocument.h/.cpp` | 作者文档反射(字段表由 `payloadSchema` 派生) |
|
||||
|
||||
### 1.2 `src/modules/rpg/` 增补(L1 消费者)
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `RpgDialect.h/.cpp` | 注册 RPG 步骤词表 + 领域 handler + `.dnut` story 装载门面 |
|
||||
| `RPG.cpp` | 脚本绑定:装载、执行、观测 |
|
||||
| `StoryEvent.h/.cpp` | 保持 v1 线格式与游标语义;新增 `.dnut` 内容源(不破坏既有 JSON 路径) |
|
||||
|
||||
### 1.3 测试与文档
|
||||
|
||||
- `test/dnut_interpreter_language.cpp`:词法、块切分、编译、诊断、注册表校验。
|
||||
- `test/dnut_interpreter_runtime.cpp`:控制流、挂起/恢复、捕获重建、预算护栏。
|
||||
- `test/rpg_dialect.cpp`:七类 RPG 步骤的语义与失败路径。
|
||||
- `docs/dev/dnut序列语言与解释器.md`:模块 owner 文档(六面边界 + 裁剪声明)。
|
||||
- `docs/usr/modules/rpg.md`:RPG 方言语法与用法。
|
||||
|
||||
## 2. RPG 方言(语言表面)
|
||||
|
||||
```
|
||||
story <id> [repeatable] {
|
||||
move actor=<id> x=<n> y=<n> [duration=<n>]
|
||||
animation target=<id> clip=<uri> [loop] [hold]
|
||||
select object=<id> [prompt=<text>]
|
||||
skill actor=<id> learn=<id> # forget=<id> 亦为同一步骤的模式
|
||||
attribute actor=<id> name=<id> op=set|add value=<n>
|
||||
item add=<id> [count=<n>] | remove=<id> [count=<n>]
|
||||
equipment actor=<id> equip=<id> | unequip=<slot>
|
||||
message text=<text>
|
||||
dialogue id=<conversationId>
|
||||
wait duration=<n>
|
||||
camera x=<n> y=<n> [duration=<n>]
|
||||
if <condition> { ... } [else { ... }]
|
||||
choice { option "label" [when <condition>] -> <target> }
|
||||
call <storyId>
|
||||
end
|
||||
}
|
||||
```
|
||||
|
||||
- 步骤类型**不是**编译器里的 `switch`:`move`/`animation`/... 全部由 `rpg` 注册,
|
||||
编译器只按注册表校验字段。
|
||||
- `if`/`choice`/`call`/`end`/`wait` 是语言核心的控制流,编译为 `branch`/`choice`/`call`/
|
||||
`end`/`wait` 节点。
|
||||
- 条件沿用 `decision::Condition` 的 `eve::Value` 形态(`var`/`op`/`value`、`all`/`any`/`not`)。
|
||||
|
||||
## 3. 验收
|
||||
|
||||
```sh
|
||||
python3 scripts/check_module_manifest.py
|
||||
python3 scripts/module_depgraph.py --check --check-layers
|
||||
make build/win32-debug
|
||||
build/win32-debug/test/unit_test.exe "dnut_interpreter.*" # 或用 CTest 过滤
|
||||
build/win32-debug/test/unit_test.exe "rpg_dialect.*"
|
||||
```
|
||||
|
||||
## 4. 未纳入本计划的既有债务(显式登记)
|
||||
|
||||
- dialogue 的 conversation/pool 方言尚未迁到 L1 语言核心(设计阶段 1 剩余部分)。
|
||||
迁移前,`.dnut` 仍有两套前端;本计划**不新增第三套**——RPG 方言直接消费 L1 词法器与
|
||||
块切分器,dialogue 的两套方言保持原状,待迁移时统一。
|
||||
|
||||
## 5. 增量:角色移动(对设计 §8 阶段 3 的有意偏离)
|
||||
|
||||
设计文档 `2026-09-15-dnut-interpreter-l1-design.md` §8 阶段 3 把移动描述为
|
||||
「**基于既有 `map::Pathfinder` + 一个按格移动控制器**」。本次交付**不那样做**,
|
||||
改为在 `rpg` 侧提供一个宿主注入的移动端口:
|
||||
|
||||
```cpp
|
||||
struct RpgStoryBinding {
|
||||
…
|
||||
StoryMoveHandler moveActor; // Result<StoryMoveStatus>(const StoryMoveRequest&)
|
||||
};
|
||||
```
|
||||
|
||||
偏离理由——设计里那条路会把方言**焊死在一种玩法上**:
|
||||
|
||||
1. `map::Pathfinder` 是**格子地图**的寻路器。把 `move` 的实现接到它上面,等于规定
|
||||
`actor` 必须是地图上的实体、`x`/`y` 必须是格坐标、移动必须存在一条格子路径。
|
||||
战棋、连续 3D、横版与轨道序列都会被迫先造一张假地图。
|
||||
2. `rpg` 是 **L1**,`map` 更高层;直接依赖会新增一条上行依赖,违反
|
||||
`scripts/module_depgraph.py --check-layers` 的判据。
|
||||
3. `move` 的真值只有「谁、去哪里、多久」——**怎么走**是玩法的自由度,不是语言事实。
|
||||
|
||||
端口形态保留了两个方向的能力:
|
||||
|
||||
- **便捷**:装一个 handler,`move` 步骤自动派发,宿主不再需要在循环里写
|
||||
`if (kind == "move")`;返回 `Arrived` 时(瞬移、已在目标点)剧情不挂起。
|
||||
- **灵活**:不装 handler 时行为**逐字不变**——仍是宿主呈现步骤,脚本读 payload 后
|
||||
`advance()`。非队伍主体(地图物件、相机、编队)也能移动,`actor` 投影为 null
|
||||
而步骤照常执行。
|
||||
|
||||
取舍明说:**本次不提供按格移动控制器**,因此「内容作者想要一条自动绕障的路径」这件事
|
||||
仍然没有开箱实现——那是某个具体玩法适配器的职责,应该建在 `map` 之上而不是
|
||||
`dnut_interpreter` 或 `rpg` 里。`x`/`y` 的单位与语义由宿主定义并在其文档中写明。
|
||||
|
||||
证据:
|
||||
|
||||
- `src/modules/rpg/RpgDialect.h`:`StoryMoveStatus` / `StoryMoveRequest` / `StoryMoveHandler`。
|
||||
- `src/modules/rpg/RpgDialect.cpp`:`performMove` + `bind("move", performMove)`;无控制器时
|
||||
返回 `Blocked`,与运行时的宿主呈现挂起**观测等价**。
|
||||
- `test/rpg_dialect.cpp`:`FakeMoveWorld` 参考端口 + 六条语义/失败/模式无关用例。
|
||||
- `test/rpg_dnut_script.cpp`:Squirrel 端到端驱动 `move` 并核对效果。
|
||||
|
||||
## 6. 增量:动画播放(与 §5 同构)
|
||||
|
||||
§2 的词表早就声明了 `animation`,但只到「编译能过、运行时把它当宿主呈现步骤挂起」为止:
|
||||
没有 handler、没有端口、没有测试、没有样例。本次按 §5 已定的同一形状补齐它,理由是
|
||||
这两步的**真值形状完全一样**——「谁、播什么、播完没有」,而「怎么播」同样是玩法的自由度。
|
||||
|
||||
端口形态:
|
||||
|
||||
```cpp
|
||||
enum class StoryAnimationStatus { Finished, Playing, Unavailable };
|
||||
struct StoryAnimationRequest { targetId, actor, clip, loop, hold };
|
||||
|
||||
struct RpgStoryBinding {
|
||||
…
|
||||
StoryAnimationHandler playAnimation; // Result<StoryAnimationStatus>(const StoryAnimationRequest&)
|
||||
};
|
||||
```
|
||||
|
||||
与 §5 一致的取舍:
|
||||
|
||||
- **便捷**:装一个 handler,`animation` 步骤自动派发,宿主不再需要在循环里写
|
||||
`if (kind == "animation")`;返回 `Finished` 时(瞬发、零长 clip)剧情不挂起。
|
||||
- **灵活**:不装 handler 时行为**逐字不变**——仍是宿主呈现步骤,脚本读 payload 后
|
||||
`advance()`。特效挂点、UI 元素等非队伍主体也能作为 `target`,`actor` 投影为 null
|
||||
而步骤照常执行。
|
||||
- **不绑死**:`clip` 对引擎是不透明字符串,不解析、不加载。接上某个动画栈(骨骼、
|
||||
Sprite、Spine、montage)是宿主适配器的职责,不是 `dnut_interpreter` 或 `rpg` 的。
|
||||
|
||||
字段命名修正:§2 原先写 `[await]`,实现与 `docs/usr/modules/rpg.md` 都是 `[hold]`。
|
||||
**以 `hold` 为准**——`await` 与步骤形状 `StepShape::Await`(该步骤本来就挂起)语义重复,
|
||||
而 `hold`(播完停在最后一帧)才是宿主真正需要的、引擎无法自行推断的信息。本次同步把
|
||||
计划书的 `[await]` 改成 `[hold]`。`loop` / `hold` 均按作者原样透传,引擎不解释。
|
||||
|
||||
证据:
|
||||
|
||||
- `src/modules/rpg/RpgDialect.h`:`StoryAnimationStatus` / `StoryAnimationRequest` / `StoryAnimationHandler`。
|
||||
- `src/modules/rpg/RpgDialect.cpp`:`performAnimation` + `bind("animation", performAnimation)`;无控制器时
|
||||
返回 `Blocked`,与运行时的宿主呈现挂起**观测等价**。
|
||||
- `test/rpg_dialect.cpp`:`FakeAnimStage` 参考端口 + 七条语义/失败/模式无关用例。
|
||||
- `test/rpg_dnut_script.cpp`:Squirrel 端到端驱动 `animation` 并核对效果。
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
# dnut_interpreter 模块设计(.dnut 序列语言与解释器下沉到 L1)
|
||||
|
||||
> 状态:**设计定案,未实施**;日期:2026-09-15
|
||||
>
|
||||
> **修订 v2**:归属从「塞进 `action` 模块」改为**新建 L1 模块 `dnut_interpreter`**,
|
||||
> 且 `.dnut` 前端(词法/语法/编译/lint)随之下沉。v1 把序列运行时放进 `action`,
|
||||
> 理由是成本(`action` 已是 L1、已永远编入、`rpg` 已依赖它、已有同族组件),
|
||||
> 但 `action` 的契约是**单个动作的执行生命周期**(`src/modules/action/Action.h:5-13`、
|
||||
> `ActionPhase` 于 `Action.h:38`),与「场景级序列解释器」在**粒度、控制方向、脚本面**
|
||||
> 三处都不匹配;且 `action` 是 `LIB EVAction`、manifest 无 SCRIPT
|
||||
> (`cmake/module_manifest/core_foundation.cmake:145-146`),装不下「脚本可扩展的语言」。
|
||||
>
|
||||
> **目标**:让引擎拥有**唯一的作者序列语言** `.dnut` 及其**唯一的解释器 owner**——
|
||||
> 语言前端、步骤类型注册表、跨帧游标与分支/调用/等待/命令派发全部归 L1;
|
||||
> `dialogue`(L6)与 `rpg`(L1)降为消费者,各自只注册自己的**块类型**与**步骤类型**;
|
||||
> 表现层(sprite / Spine / 3D skeletal / Live2D / 地图 / 相机 / 音频)通过注册命令
|
||||
> handler 接入,不进入语言与运行时。
|
||||
>
|
||||
> **涉及改动**:新增 `src/modules/dnut_interpreter/`;
|
||||
> `cmake/module_manifest/core_foundation.cmake` 新增一条 L1 声明;
|
||||
> `src/modules/dialogue/` 大面积迁移(§7 逐文件清单);
|
||||
> `cmake/module_manifest/orchestration.cmake:238` 给 `dialogue` 加依赖;
|
||||
> `src/modules/rpg/StoryEvent.{h,cpp}` 接入;8 个测试文件、3 份模块文档。
|
||||
|
||||
## 1. 背景与现状
|
||||
|
||||
### 1.1 `.dnut` 今天是什么(2026-09-15 代码核对)
|
||||
|
||||
`.dnut` 已经在,但**一个扩展名下有两套互不相识的前端和两个入口**:
|
||||
|
||||
| 方言 | 解析器 | 产物 | 入口 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| `pool { when … speaker: "…" }` | `dialogue/DnutParser.cpp`(真词法分析器 + 递归下降,`tokenize()` 于 `DnutParser.cpp:101-217`) | `DataValue` 根表(`DataValue` 是 `eve::Value` 的别名,`Dialogue.h:34`) | `Dialogue::loadPoolsFromDnut` / `loadPoolsFromDnutFile`(`Dialogue.h:138-140`) | `dialogue/Dialogue.h`(L6) |
|
||||
| `conversation … node … when … -> …` | `dialogue/ConversationCompiler.cpp:85-177`(逐行 `words()` 切分,无词法器) | `ConversationAsset`(`Conversation.h:40-75`) | `DialogueFlow::loadFromDnut` / `loadFromDnutFile`(`DialogueFlow.h:100-102`) | `dialogue/Conversation.h`(L6) |
|
||||
|
||||
- **「两方言可共存于同一文件」只有一侧成立。** `docs/usr/modules/dialogue.md:169`
|
||||
如此声明,`ConversationCompiler.cpp:129-131` 也确实显式跳过 pool 语法;但
|
||||
`parseDnut` 遇到顶层 `conversation` 块会在 `DnutParser.cpp:414-415`
|
||||
(`expectIdent("pool")`)直接失败。今天 `examples/dialogue/pools.dnut` 与
|
||||
`examples/rpg-classic/data/village-dialogue.dnut` 是两份不同方言的文件,
|
||||
靠两个不同入口分别加载。
|
||||
- **conversation 方言的词表是编译期闭集。** 节点种类 `line|branch|choice|call|command|wait|end`
|
||||
硬编码在 `ConversationCompiler.cpp:61-72`;属性名硬编码在
|
||||
`ConversationCompiler.cpp:147-155`(`next/speaker/text/pool/i18n/voice/target/return/result`)。
|
||||
没有任何区块或步骤的注册入口。
|
||||
- **作者侧已经有一份手写反射。** `ConversationAuthoring.h` 的
|
||||
`ConversationDocument::getFieldCount/getFieldName/getFieldKind/getField/setField`
|
||||
按节点种类列出可编辑字段——这是「步骤类型描述符」的事实前身,但它是第二份真相源,
|
||||
与 `ConversationCompiler.cpp:147-155` 的属性表各自维护。
|
||||
- **控制流与领域焊在同一个类型里。** `ConversationAsset::Node` 同时装
|
||||
`Branch/Choice/Call/Command/Wait/End`(控制流)与 `speaker/text/pool/i18nKey/voice/expression`
|
||||
(对话领域),以及 `PaymentSpec`/`StateMutation`/`CommandRequestKind`(对话事务语义)。
|
||||
|
||||
### 1.2 执行与消费现状
|
||||
|
||||
- 执行器 `ConversationRunner`(`Conversation.h:78-228`,实现于 `Conversation.cpp`)
|
||||
已经有完整的跨帧游标:`runUntilBlocked`(`Conversation.cpp:167-272`,含
|
||||
`Conversation.cpp:168` 的 10000 步预算护栏)、`advance`(`:398`)、`select`(`:407`)、
|
||||
`selectRouteForTransaction`(`:424`)、`resumeCommand` 双重载(`:373`、`:394`)、
|
||||
`captureState`/`restoreState`(`:296`/`:310`,帧内用 `captureFrame`,`:283-292`)。
|
||||
- 消费面**全部在 dialogue 模块内**:`ConversationImporter`(Yarn/Twee)、
|
||||
`ConversationToolchain`(跨资产 lint 与重命名)、`ConversationAuthoring`
|
||||
(`ConversationDocument`)、`ConversationPersistence`(存档 JSON + 版本迁移)、
|
||||
`ConversationLocalization`、`editing/DialogueGraph`、`editor/EditorDialoguePreview`、
|
||||
`DialogueFlow`。模块外只有测试与 `examples/`(`test/dialogue_*.cpp` 共 17 个,
|
||||
其中 8 个直接包含迁移中的头文件;另有 `test/rpg_classic*.cpp`、
|
||||
`examples/rpg-classic/main.nut`);`src/` 下唯一的跨模块引用是
|
||||
`src/modules/i18n/editing/LocalizationRuntime.cpp` 引用 `DialogueVoice.h`(与本次无关)。
|
||||
|
||||
### 1.3 通用机制其实已经齐了,缺的是一个 owner
|
||||
|
||||
| 已有能力 | 归属 | 层 | 提供什么 |
|
||||
|---|---|---|---|
|
||||
| 具名命令描述符 + handler 路由 + 校验 | `action::ActionNotifyRegistry` | L1 | `type + displayName + category + shape(Instant/State) + requiredPayloadFields`;`registerDescriptor`/`registerHandler`/`validate`/`dispatch`;`withBuiltins()` 已含 `presentation:vfx`/`presentation:audio`/`presentation:camera`(`ActionNotifyRegistry.cpp:21-34`) |
|
||||
| 表现引用不绑后端 | `action::ActionTimeline` | L1 | 8 条语义轨道、Notify/StateEnter/StateExit、确定性 `sample(previous,current)`;"resources are referenced by URI/type payloads and resolved by downstream adapters"(`ActionTimeline.h:99-101`) |
|
||||
| 分支条件 AST | `decision::Condition` | L0 | `All/Any/Not/Compare/HasTag/HasAttribute/HasResource/StateEquals/AuthorityCheck/PolicyCall`(`Condition.h:20`) |
|
||||
| 脚本可注册策略 | `policyregistry::PolicyRegistry` | L0 | `ImplementationKind { Builtin, Script, Batch }` + generation 失效 |
|
||||
| 宿主中立命令/观测 | `common::IGameplayControlProvider` | L0 | action descriptor 带 `parameterSchema`、command 带 `expectedRevision`、Capability 多 provider |
|
||||
| 热重载 | `filesystem::HotReload` + `Runtime::reload(ScriptId)` + 目录原子替换范式 | — | `HotReload.h:19-33`、`Runtime.h:357`、`StoryEvent.h:62` |
|
||||
|
||||
### 1.4 rpg 侧只有平铺数据,且够不着 dialogue
|
||||
|
||||
- `enum class StoryEventStepKind { Dialogue, Message, Wait, Move, Camera }`
|
||||
(`StoryEvent.h:15`)是编译期闭集,1–128 步线性序列,固定字段
|
||||
`{reference, actorId, x, y, duration}`(`StoryEvent.h:18-25`);`StoryEvent.cpp:33-58`
|
||||
的 `parseKind`/`kindName` 是唯一的种类真相源。没有分支、循环、标签、公共事件、
|
||||
脚本命令、开关/变量指令、移动路线、动画/音频/画面特效指令,也没有命令注册入口。
|
||||
- 执行逻辑在宿主脚本里:`examples/rpg-classic/main.nut:714-753` 用
|
||||
`getStepKind()` 比较 + if/else 链逐条驱动。
|
||||
- **决定性约束:`rpg` 够不着 `dialogue`。** `rpg` 是 **LAYER 1**
|
||||
(`core_foundation.cmake:102-103`),`dialogue` 是 **LAYER 6**
|
||||
(`orchestration.cmake:238-241`)。L1 依赖 L6 会被
|
||||
`scripts/module_depgraph.py --check-layers` 判失败。这就是 `StoryEvent` 为何是闭集枚举。
|
||||
|
||||
## 2. 问题定义
|
||||
|
||||
缺的不是「语言基础设施」,而是三件绑定在一起的事:
|
||||
|
||||
1. **一个 owner 负责「作者序列语言 + 跨帧游标 + 场景级控制流」。**
|
||||
`ActionTimeline` 是**单个动作内部**的采样器(无跨帧游标、无分支、无挂起);
|
||||
`common::IGameplayControlProvider` 是**单条命令**的提交/观测协议(无序列);
|
||||
`TransitionRunner` 有完整控制流但在 L6 且与对话领域焊死。
|
||||
2. **`.dnut` 前端的分裂要收口。** 同一扩展名两套词法、两个入口、两种产物,
|
||||
且「共处一文件」只有半侧成立;新增步骤类型必须改 `ConversationCompiler.cpp:61-72`
|
||||
的闭集。没有统一前端,就不存在「可扩展的语言」。
|
||||
3. **`.dnut` 必须对 rpg 可用。** rpg 在 L1,前端与运行时必须在 L1 或更低,
|
||||
否则 rpg 只能继续写第二套闭集枚举。
|
||||
|
||||
因此本次工作有三半,缺一不可:
|
||||
|
||||
1. **新建**:L1 模块 `dnut_interpreter`,拥有 `.dnut` 前端 + 资产模型 + 步骤类型注册表 + 运行时。
|
||||
2. **迁移**:`dialogue` 的 conversation 方言与执行器下沉/改写为它的消费者,
|
||||
领域语义(payment / stateMutations / i18n / voice / 台词池 / UX)留在 dialogue。
|
||||
3. **接入**:`rpg` 剧情事件改为该运行时上的注册步骤类型;两个消费者各自只注册词表。
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
1. **一个事实一个 owner。** `.dnut` 的语法、资产模型、序列游标、命令派发归
|
||||
`dnut_interpreter`;条件归 `decision::Condition`;命令词表归
|
||||
`action::ActionNotifyRegistry`;持久游标/完成事实/开关变量归 `rpg::GameState`
|
||||
(`StoryEvent.h:61-65` 已确立该契约);命令的实际执行归各适配器。
|
||||
2. **语言不懂领域。** `line` / `pool` / `story:move` / `story:animation` 都是**注册项**,
|
||||
不是编译器或运行时里的 `switch` 分支。`dialogue` 注册 `line` 步骤与 `pool` 块;
|
||||
`rpg` 注册 `story:*` 步骤。
|
||||
3. **命令只发意图,不绑表现后端。** payload 一律 URI/type 字符串,沿用
|
||||
`ActionTimeline.h:99-101` 的既定原则。sprite / Spine / 3D skeletal / Live2D
|
||||
的差别落在 handler 实现上,换后端不改脚本。
|
||||
4. **运行时不断言品类。** 它只推进游标、求条件、派发步骤;宿主边界靠
|
||||
`GameState` 游标 + 适配器注册,因此俯视 tile RPG / HD2D / 3D 动作共用同一份脚本。
|
||||
5. **保语义搬迁。** 挂起/恢复、事务化选择、状态捕获三处逐字保语义(§5.5),
|
||||
不顺手重构;`.dnut` 既有源码的**接受集不变**(§5.9)。
|
||||
6. **可重载。** 沿用已证范式:严格校验 → 原子替换目录 → 活动会话持定义副本 →
|
||||
事件内存档后重放未确认步骤。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```text
|
||||
L1 dnut_interpreter(新模块)
|
||||
语言前端 DnutLexer 统一的 .dnut 词法器(今天的 tokenizer 升格)
|
||||
DnutBlockScanner 顶层块切分:pool{…} / conversation…endconversation
|
||||
DnutCompiler conversation 块 → SequenceAsset,按注册表校验步骤类型
|
||||
DnutLint 可达性 / 重复 id / 跨资产引用 / 迁移校验
|
||||
资产模型 SequenceAsset / SequenceNode{id,type,next,payload,routes}
|
||||
SequenceRoute{label,condition,target,payload}
|
||||
SequenceDocument 可编辑文档(取代手写反射的 ConversationDocument)
|
||||
步骤词表 StepKindRegistry descriptor + handler + payloadSchema + validate
|
||||
BlockKindRegistry (后续增量)块类型的注册入口
|
||||
运行时 SequenceRuntime 跨帧游标 + 调用栈 + 挂起/恢复 + 状态捕获 + 预算护栏
|
||||
SequenceCommandRequest / Response / Handler
|
||||
持久化 SequenceSaveMigrations 资产 id + version + node 迁移(与语言无关)
|
||||
|
||||
L0 decision::Condition 条件求值(不变)
|
||||
policyregistry 脚本策略(不变)
|
||||
common::Value/StateValue 值类型(不变)
|
||||
|
||||
L6 dialogue(消费者)
|
||||
注册:步骤 `line`、块 `pool`
|
||||
保留:台词池/Dialogue、PaymentSpec、StateMutation、i18n、voice、UX、文本渲染、
|
||||
Yarn/Twee 导入、存档 JSON 编解码
|
||||
删除:ConversationAsset / ConversationRunner(被 L1 资产模型与运行时取代)
|
||||
|
||||
L1 rpg(消费者)
|
||||
注册:`story:dialogue` / `story:message` / `story:wait` / `story:move` / `story:camera`
|
||||
(后续)`story:animation` / `story:expression` / `story:wait-for-motion`
|
||||
保留:GameState(持久游标与完成事实的唯一权威)、StoryEventCatalogue 的内容目录契约
|
||||
|
||||
适配器(各自层)注册命令 handler
|
||||
avatar / animation / map / audio / ui / camera …
|
||||
```
|
||||
|
||||
## 5. 详细设计
|
||||
|
||||
### 5.1 归属判据:逐文件/逐类型的迁移表
|
||||
|
||||
`dialogue` 现有 33 个文件按归属分类(依据:`Conversation*`/`Dnut*` 的 `#include` 图与类型用法):
|
||||
|
||||
| 今天的文件/类型 | 归属 | 理由 |
|
||||
|---|---|---|
|
||||
| `DnutParser` 的 `tokenize()`(`DnutParser.cpp:101-217`) | **L1** | `.dnut` 词法,两种方言共用 |
|
||||
| `ConversationCompiler`(语句解析、`nodeKind`、`attributes`、`lintConversations`) | **L1** | conversation 方言的语法与图校验;节点种类改由注册表解析 |
|
||||
| `ConversationAsset` / `Node` / `ConversationRoute`(`Conversation.h:22-75`) | **L1** | 通用资产模型;领域字段改入 `payload` |
|
||||
| `ConversationAsset::validate`(`Conversation.cpp:23-65`) | **L1 + dialogue** | 通用图校验(id/入口/引用)归 L1;`payment`/`stateMutations` 的「仅 command/choice 可用」规则归 dialogue 的步骤校验 |
|
||||
| `ConversationRunner`(`Conversation.h:78-228`、`Conversation.cpp:67-459`) | **L1** | 纯控制流 + 游标(§5.5 三处逐字保语义) |
|
||||
| `ConversationRunner::CommandResult/Event/CommandHandler` | **L1** | 通用命令与事件类型 |
|
||||
| `CommandRequest`/`CommandResponse`/`CommandRequestKind`(`DialogueState.h:23-58`) | **L1(通用部分)+ dialogue(领域字段)** | `name/arguments/bindings/locals` 通用;`kind/payment/stateMutations` 是对话事务语义,留 dialogue |
|
||||
| `toCanonicalValue`/`toDialogueStateValue`(`DialogueState.h:65,72`) | **dialogue** | 对话侧 legacy 值转换,L1 不引用 |
|
||||
| `ConversationPersistence` 的 `ConversationSaveMigrations` | **L1** | 只依赖「资产 id + version + 节点 id 映射」,与领域无关 |
|
||||
| `ConversationPersistence` 的 StateValue↔JSON | **dialogue** | 对话存档格式 |
|
||||
| `ConversationToolchain`(跨资产 lint、`renameConversationAsset/Node`) | **L1** | 全是对资产图的操作 |
|
||||
| `ConversationAuthoring`(`ConversationDocument`) | **L1** | 手写字段反射正是步骤描述符的 `payloadSchema`;保留即第二真相源 |
|
||||
| `ConversationImporter`(Yarn / Twee) | **dialogue** | 外部对话格式到 `line` 步骤的映射是对话领域知识;产物是 L1 资产 |
|
||||
| `ConversationLocalization` / `ConversationText` / `DialoguePayment` / `DialogueState` / `Dialogue.{h,cpp}` / `DialogueUX` / `DialogueVoice` / `editing/DialogueGraph` / `editor/EditorDialoguePreview` | **dialogue** | 领域载荷、本地化、表现与作者 UI |
|
||||
| `DialogueFlow`(`DialogueFlow.h:33-232`) | **dialogue** | 脚本门面;内部改为持有 L1 运行时并注册 `line` 步骤 |
|
||||
|
||||
### 5.2 资产模型
|
||||
|
||||
```text
|
||||
SequenceAsset {
|
||||
id, version, entry, parameters[],
|
||||
nodes[]: SequenceNode { id, type, next, payload, routes[] }
|
||||
}
|
||||
SequenceRoute { label, condition, target, payload }
|
||||
```
|
||||
|
||||
- `type` 是**字符串**步骤类型名(`line` / `branch` / `choice` / `call` / `command` / `wait` /
|
||||
`end` / `story:move` / …),不再是闭集枚举。
|
||||
- `payload` 是 `eve::Value::Object`:步骤类型自己的字段,由该类型的 descriptor 校验与解释。
|
||||
`line` 的 `speaker/text/pool/i18n/voice/expression`、`command` 的 `arguments/payment/stateMutations`、
|
||||
`choice` 的每条路由 `payment/stateMutations` 都进 payload。
|
||||
- `condition` 沿用 `eve::Value`(今天 `ConversationRoute::condition` 即如此,`Conversation.h:25`)。
|
||||
|
||||
**为什么不让 `ConversationAsset` 留在 dialogue 与 L1 资产并存。** 并存就是同一份内容的两份
|
||||
表示,正是本次要消除的「第二套控制流」;而且运行时的 `dispatch` 必须把 payload 交给**领域
|
||||
注册的 handler**,L1 不可能知道 dialogue 的强类型结构,所以运行时一侧本来就必须接受弱类型
|
||||
payload。既然运行时已经弱类型,资产再强类型只会多一次同步。dialogue 侧改为**无状态的
|
||||
payload 编解码**(`decodeLinePayload(node)` 返回强类型视图),不持有第二份资产。
|
||||
|
||||
**拒绝的替代方案**:L1 定义资产**接口**(`ISequenceAsset` + 视图),dialogue 保留具体强类型资产
|
||||
实现它。好处是 dialogue 侧改动小;坏处是每个领域字段都要走一次虚函数取值,且「步骤类型
|
||||
的字段表」这个通用事实仍留在 dialogue,`ConversationDocument` 的手写反射也留着。故不采用。
|
||||
|
||||
### 5.3 步骤类型与块类型注册表
|
||||
|
||||
复用 `ActionNotifyRegistry` 已证明的形态(`ActionNotifyRegistry.h:19-71`),不新造机制:
|
||||
|
||||
```text
|
||||
StepKindDescriptor {
|
||||
type, displayName, category,
|
||||
shape(Instant | State | Await), // Await = 阻塞直到宿主 ack
|
||||
payloadSchema, // 字段表:同时供编译器严格校验与作者 UI 反射
|
||||
validate(payload) -> Result<void> // 领域不变量(如 payment 只允许出现在 command/choice)
|
||||
}
|
||||
registerStep(type, descriptor, handler) / unregisterStep(type)
|
||||
validate(node) / dispatch(node, context)
|
||||
```
|
||||
|
||||
- `conversation` 块本身由 L1 编译;`pool` 块在阶段 1 仍由 dialogue 的池解析器消费,
|
||||
但**共用 L1 的词法器与顶层块切分**(`DnutBlockScanner`)。块类型的完整注册入口
|
||||
(`BlockKindRegistry`)列为后续增量,见 §11。
|
||||
- `dialogue` 注册 `line`(Await)与 `choice` 的领域校验;`rpg` 注册 `story:*`(§4)。
|
||||
- 适配器注册命令:`avatar`/`animation`/`map`/`audio`/`ui` 各自实现
|
||||
`presentation:*` / `movement:*` 的 handler。
|
||||
- `ConversationDocument` 的 `getFieldName/getFieldKind` 反射(`ConversationAuthoring.h`)
|
||||
由 `payloadSchema` 派生,消除第二份字段表。
|
||||
|
||||
### 5.4 控制流集合
|
||||
|
||||
**本设计交付**(与今天 `ConversationRunner` 等价,纯搬迁):
|
||||
|
||||
```text
|
||||
Branch 取第一条条件通过的 route,否则 next (Conversation.cpp:184-189)
|
||||
Call 压栈 + 切入被调用资产 + bindings mergeDefaults + 结束帧时弹栈恢复(:204-225)
|
||||
Command 派发具名命令;Blocked → blocked_ + waitingCommand_;Failed → 失败(:226-268)
|
||||
Wait 阻塞,等宿主 advance (:181-183)
|
||||
End 栈空则 Ended + stop,否则弹栈回到 returnNode (:190-203)
|
||||
```
|
||||
|
||||
**后续增量**(不在本设计交付,但 owner 已就位):
|
||||
`Loop / BreakLoop / Label + Jump / CallCommonEvent / Parallel`。
|
||||
|
||||
### 5.5 必须逐字保语义的四处
|
||||
|
||||
1. **双重重载**:`resumeCommand(StateValue)`(`Conversation.cpp:373`)与
|
||||
`resumeCommand(eve::Value)`(`Conversation.cpp:394`)。L1 运行时**两个都保留**,
|
||||
不做「只留 canonical 版本 + dialogue 加转换壳」的改写。理由:`common/StateValue.h`
|
||||
是 L0 且 L1 已有直接使用先例(`src/modules/rpg/RpgState.h:3`);保留双载 = 零转换
|
||||
= 零行为变化,引入转换壳反而制造一条需要单独证明等价的新路径。
|
||||
`toCanonicalValue` / `toDialogueStateValue`(`DialogueState.h:65,72`)留在 dialogue,不被 L1 引用。
|
||||
2. **事务化选择**:`selectRouteForTransaction`(`Conversation.cpp:424-459`)与
|
||||
`DialogueFlow.cpp` 的支付/状态事务包装。运行时的 `select` 必须保持
|
||||
「选中即提交、失败保持游标」的现状语义(`:445-455` 的 capture → enter → restore 序列),
|
||||
事务包装留在 dialogue。
|
||||
3. **调用栈重建**:`captureState` 存的是 `{asset id, asset version, node, bindings, locals}`
|
||||
(`Conversation.cpp:283-292`、`:296-308`),即**稳定引用而非指针**。L1 必须延续这个形态
|
||||
(含 version 校验,`:327-329`、`:346-353`),不得简化成裸 id。
|
||||
4. **`advance` 对 choice 的拒绝**:`advance` 在 choice 节点必须返回
|
||||
"select a choice route instead"(`Conversation.cpp:401-402`),不能被通用化掉。
|
||||
|
||||
另外 `runUntilBlocked` 的 10000 步执行预算(`Conversation.cpp:168`)必须保留——
|
||||
它是脚本内容写错死循环时的唯一护栏,属于通用行为。
|
||||
|
||||
### 5.6 命令请求与响应
|
||||
|
||||
通用层(L1)——值类型与今天逐字一致,不引入新转换:
|
||||
|
||||
```text
|
||||
SequenceCommandRequest { name, arguments, bindings, locals } // 全部 eve::Value,同今天 CommandRequest
|
||||
SequenceCommandResponse { Status(Completed|Blocked|Failed), value, error }
|
||||
SequenceCommandHandler = std::function<SequenceCommandResponse(const SequenceCommandRequest&)>
|
||||
```
|
||||
|
||||
帧内 bindings/locals 继续用 `eve::StateValue`(今天 `ConversationRunner::Frame` 就是这样),
|
||||
与 canonical `eve::Value` 的边界保持今天的模样:命令请求/响应走 `eve::Value`
|
||||
(`CommandRequest` 今天即如此,`DialogueState.h:39-41`),legacy handler 走 `StateValue`
|
||||
(`Conversation.h:97` 的 `CommandHandler` 今天即如此)。
|
||||
|
||||
dialogue 侧保留领域扩展(`CommandRequest` 继续带 `payment` / `stateMutations` / `kind`,
|
||||
`DialogueState.h:36-46`),由 dialogue 注册的 handler 做适配转换。
|
||||
**不得**把 `PaymentSpec` / `StateMutation` 提到 L1——它们属于 dialogue 的事务语义。
|
||||
|
||||
### 5.7 挂起、事件与条件
|
||||
|
||||
- 挂起:`Blocked` → `blocked_ = true` + `waitingCommand_ = true` → 宿主
|
||||
`advance()` 或 `resumeCommand(value)` 继续。语义不变。
|
||||
- 事件:运行时发出通用事件(`Started/NodeEntered/Command/Ended/Failed`);
|
||||
步骤 handler 发出领域事件(`Line/Choice/…`)。`setEventSink` 保持单一出口。
|
||||
- 条件:`setConditionEvaluator` 继续委托 `eve::decision::ConditionResult`,
|
||||
dialogue 侧的 legacy 分支表达式兼容路径(`evaluateRoute`,`Conversation.cpp:140-165`)
|
||||
留在 dialogue 的 evaluator 实现里。
|
||||
|
||||
### 5.8 热重载与存档迁移
|
||||
|
||||
沿用 `StoryEvent` 已证范式(`StoryEvent.h:61-65`、`docs/usr/modules/rpg.md:274-276`):
|
||||
|
||||
- 内容目录严格校验 + 原子替换,失败保留上一份。
|
||||
- 活动会话持**定义副本**,所以目录热替换不产生悬垂引用。
|
||||
- 捕获的状态用**稳定 id + version** 引用资产,恢复时按新定义重新解析(§5.5 第 3 点)。
|
||||
- 存档迁移 `SequenceSaveMigrations` 的规则全是资产级事实
|
||||
(`ConversationPersistence.h` 的 `Rule{assetId, fromVersion, currentAssetId, nodes}`),随之下沉。
|
||||
- 事件中存档 → 读档后重放尚未确认的步骤(`examples/rpg-classic/main.nut:1967-1998`
|
||||
已有该行为雏形)。
|
||||
- 脚本侧热重载沿用 `filesystem::HotReload` + `Runtime::reload(ScriptId)`,本设计不新增重载机制。
|
||||
|
||||
### 5.9 语言兼容性契约
|
||||
|
||||
前端下沉是**改写**而非移动(今天的 conversation 方言没有词法器,pool 方言有),
|
||||
因此验收必须钉住「接受集不变」:
|
||||
|
||||
- 现有 `.dnut` 源码(`examples/dialogue/pools.dnut`、
|
||||
`examples/rpg-classic/data/village-dialogue.dnut` 及测试内联源)必须编译出**等价资产**:
|
||||
相同的 `id`/`version`/`entry`/`parameters`、相同的节点 id 与类型、相同的路由与目标、
|
||||
相同的诊断(`ConversationDiagnostic` → L1 `DnutDiagnostic`,字段逐字保留:
|
||||
severity/path/line/message)。
|
||||
- **行为改善处需显式声明**:今天是 `parseDnut` 无法读含 `conversation` 块的文件
|
||||
(`DnutParser.cpp:414-415`),统一前端后两种方言可在同一文件被同一入口读入。
|
||||
这是有意扩大的接受集,必须在文档与测试中写明,不能作为「无声兼容」。
|
||||
- 语法收紧要禁止:不得因为改写而拒绝今天能编译的源。
|
||||
|
||||
## 6. 分层影响与模块声明
|
||||
|
||||
- **新建 L1 模块 `dnut_interpreter`。** 声明插入
|
||||
`cmake/module_manifest/core_foundation.cmake` 的 `# L1 -- sensing/action protocol`
|
||||
段(`:133`)内、`action` 声明块(`:145-147`)之后、`# L2 -- combat resolution`
|
||||
(`:148`)之前——`scripts/check_module_manifest.py:216-227` 要求声明必须落在匹配的
|
||||
`# L<n>` 段内。
|
||||
- **声明形态**:
|
||||
|
||||
```cmake
|
||||
# .dnut authored-sequence language, compiler, step-kind registry and cross-frame
|
||||
# interpreter. Domain vocabularies (dialogue lines/pools, RPG story steps) are
|
||||
# registered by their owning modules; this core never interprets a domain payload.
|
||||
eve_declare_module(NAME dnut_interpreter LIB EVDnutInterpreter LAYER 1
|
||||
SCRIPT DnutInterpreter SLOT dnutInterpreter
|
||||
DEPS decision
|
||||
GROUP minimal 2d 3d web)
|
||||
```
|
||||
|
||||
`GROUP` 取值见 §11 待决问题 3;`SCRIPT`/`SLOT` 见 §11 待决问题 4。
|
||||
`LIB`+`SCRIPT` 同时使用有先例:`rpg LIB EVRPG LAYER 1 SCRIPT RPG`(`:102`)。
|
||||
- **依赖合法性**:`dialogue`(L6)→ `dnut_interpreter`(L1)、`rpg`(L1)→
|
||||
`dnut_interpreter`(L1)都是合法下行/同层依赖;`scripts/module_depgraph.py:179`
|
||||
明确「Same-layer edges (e.g. window -> image) are allowed」,同层先例是
|
||||
`rpg`(L1)→ `action`(L1)。`dnut_interpreter` 不依赖 dialogue/rpg,无环。
|
||||
- **`rpg` 的依赖声明**:`core_foundation.cmake:102-103` 的 `DEPS` 增加 `dnut_interpreter`。
|
||||
- **`dialogue` 的依赖声明**:`orchestration.cmake:238-241` 的 `DEPS` 增加 `dnut_interpreter`。
|
||||
`dialogue` 无 `GROUP`,加依赖不改变任何裁剪档位。
|
||||
- **裁剪影响**:`dnut_interpreter` 的消费者(`dialogue`/`rpg`)都不声明 `GROUP`,
|
||||
因此它不应声明 `GROUP`(§11 待决问题 3);若声明 `GROUP minimal 2d 3d web`
|
||||
会被每个裁剪档无条件编入。裁剪声明按 AGENTS.md 要求给出层号与符号/构建证据。
|
||||
|
||||
## 7. 影响面清单
|
||||
|
||||
**新增**
|
||||
|
||||
- `src/modules/dnut_interpreter/`:`DnutLexer`、`DnutBlockScanner`、`DnutCompiler`、
|
||||
`DnutLint`、`SequenceAsset.h`、`StepKindRegistry`、`SequenceRuntime`、`SequenceDocument`、
|
||||
`SequenceSaveMigrations`。
|
||||
- `test/dnut_interpreter_sequence.cpp`(控制流 + 挂起/恢复 + 捕获重建 + 预算护栏 + 注册表校验)、
|
||||
`test/dnut_interpreter_language.cpp`(词法/块切分/编译/lint/方言共处 + 诊断保真)。
|
||||
测试按 AGENTS.md 独立成文件,不进共享 test main。
|
||||
|
||||
**迁移(dialogue 内)**
|
||||
|
||||
- `DnutParser` 的 `tokenize()`(`DnutParser.cpp:101-217`)→ L1 `DnutLexer`;`parseDnut` 保留在
|
||||
`dialogue`,改为消费 L1 词法与顶层块切分。
|
||||
- `ConversationCompiler.{h,cpp}`、`Conversation.{h,cpp}`、`ConversationToolchain.{h,cpp}`、
|
||||
`ConversationAuthoring.{h,cpp}`、`ConversationPersistence` 的迁移部分 → L1。
|
||||
- `ConversationImporter.{h,cpp}`、`ConversationLocalization.{h,cpp}`、`ConversationText.{h,cpp}`、
|
||||
`DialogueFlow.{h,cpp}`、`dialogue/editing/DialogueGraph*`、`dialogue/editor/*` → 改为消费 L1 资产模型。
|
||||
- `DialoguePayment.{h,cpp}`、`DialogueState.{h,cpp}`、`Dialogue.{h,cpp}`、`DialogueUX.*`、
|
||||
`DialogueVoice.*` → 原地不动(领域语义)。
|
||||
|
||||
**测试**:`test/dialogue_conversation.cpp`、`test/dialogue_compiler.cpp`、
|
||||
`test/dialogue_proc.cpp`、`test/dialogue_toolchain.cpp`、`test/dialogue_authoring.cpp`、
|
||||
`test/dialogue_persistence.cpp`、`test/dialogue_localization.cpp`、`test/dialogue_importers.cpp`
|
||||
跟随类型变化;`test/rpg_classic.cpp:242`、`test/rpg_classic_playthrough.cpp:130` 直接使用
|
||||
`compileDnutConversations`,需改为 L1 入口。
|
||||
|
||||
**文档**:新增 `docs/dev/dnut_interpreter 序列语言与解释器.md`(模块 owner 文档,
|
||||
六面边界与裁剪声明写在这里);更新 `docs/usr/modules/dialogue.md:168-180,275`(`.dnut` 语法
|
||||
与 `loadPoolsFromDnut` 的归属指针)、`docs/dev/对话与Avatar模块设计.md:220`(`.dnut` 内容格式的
|
||||
owner 变更)、`docs/dev/战斗系统与动作编辑器.md`(`action` 仍是单动作管线,序列语言不在其中)、
|
||||
`docs/usr/MODULES.md:75`(若新模块有用户文档则登记)。
|
||||
|
||||
**示例**:`examples/dialogue/main.nut:133-135`(`loadPoolsFromDnutFile` 热重载路径)、
|
||||
`examples/rpg-classic/main.nut:439-442,573`(`dialogueFlow.loadFromDnut`)在阶段 1 保持可用,
|
||||
但内部路径改为 L1 前端。
|
||||
|
||||
## 8. 分阶段落地
|
||||
|
||||
1. **模块与语言(阶段 1,一个 PR)**:新建 `dnut_interpreter`(词法 + 块切分 + conversation
|
||||
编译/lint + 资产模型 + 步骤类型注册表 + 运行时 + `SequenceDocument` + 迁移),
|
||||
并**在同一个 PR 内**把 `dialogue` 全部消费者切过去(§7)。
|
||||
验收 = 现有 dialogue/rpg 测试全绿 + §5.9 的接受集契约。
|
||||
AGENTS.md 的「接口变化一个 PR」要求不留中间破坏 CI 的 commit,所以阶段 1 不能只做一半。
|
||||
2. **rpg 接入(阶段 2,一个 PR)**:`StoryEvent` 改用通用运行时;注册五个既有步骤类型;
|
||||
保持 `eve.rpg.story-events` v1 的线格式与游标语义完全兼容;宿主脚本
|
||||
`examples/rpg-classic/main.nut:714-753` 的 if/else 链退役。
|
||||
3. **补表现命令(阶段 3)**:`story:animation`、`story:expression`、`story:wait-for-motion`、
|
||||
移动路线(基于既有 `map::Pathfinder` + 一个按格移动控制器;
|
||||
`docs/dev/2.5D-tilemap-developer-evaluation.md:61` 已记录该缺口)。
|
||||
4. **补控制流与脚本扩展面(阶段 4)**:`Loop / Label+Jump / CallCommonEvent / Parallel`,
|
||||
块类型注册入口 `BlockKindRegistry`,脚本侧注册步骤/块的能力,以及 lint 与 i18n 抽取的扩展。
|
||||
|
||||
阶段 1、2 是「不改内容格式」的架构收敛;阶段 3、4 是可交付能力,各自独立 PR。
|
||||
|
||||
## 9. 验收与门禁
|
||||
|
||||
阶段 1 的机械验收:
|
||||
|
||||
```sh
|
||||
python3 scripts/check_module_manifest.py # 新声明落在 L1 段内、目录存在
|
||||
python3 scripts/module_depgraph.py --check-layers # L1 不出现对 L6 的依赖
|
||||
ARCHITECTURE_BASE=HEAD make check/architecture-contracts
|
||||
make check/quality-metadata
|
||||
git clang-format # 只格式化改动行
|
||||
```
|
||||
|
||||
行为保持验收(必须覆盖 §5.5 的四处):
|
||||
|
||||
- `resumeCommand`:`Blocked` → `advance`/`resumeCommand` → 继续的两条路径行为不变。
|
||||
- `selectRouteForTransaction`:成功提交与失败保持游标两种情况不变;
|
||||
`advance` 在 choice 上仍拒绝。
|
||||
- `captureState`/`restoreState`:含 0 层与多层调用栈、bindings/locals 保真、
|
||||
asset version 不匹配时明确失败。
|
||||
- 执行预算护栏:构造超长/自环内容必须仍以预算耗尽失败,不挂死。
|
||||
- 语言接受集:§5.9 的等价性用例(既有源 → 等价资产 + 等价诊断)。
|
||||
|
||||
契约条目:本设计改变的是「谁拥有 `.dnut` 的语法、序列游标与命令派发」,属可执行的
|
||||
`state-owner` 与 `api-lifetime` 范围,按需在 `scripts/architecture_contracts.json` 补齐条目。
|
||||
|
||||
> **门禁陷阱(已核对,勿踩):** AGENTS.md 要求新模块补 `module-interface` catalogue 条目,
|
||||
> 但 `scripts/check_architecture_contracts.py:47-58` 的 `RULES` 里没有 `module-interface`
|
||||
> (实际为 `api-shape`/`link`/`state-owner`/`ecs-system`/`time-rng`/`api-lifetime`/`persistence`/
|
||||
> `optional-capability`/`backend-contract`/`debt-metadata` 十项),写这种条目会被判
|
||||
> `rule must be one of …` 直接失败(`docs/dev/2026-09-15-模块边界审查台账.md:199`
|
||||
> 也确认该机制尚未存在)。本设计**不**往 `architecture_contracts.json` 写非法 rule;
|
||||
> 新模块的六面边界写进它的 owner 文档,并用可执行的 `state-owner`/`api-lifetime` 条目表达。
|
||||
|
||||
## 10. 非目标
|
||||
|
||||
- 不改变 `eve.rpg.story-events` / `eve.dialogue.*` 的既有线格式与存档兼容性。
|
||||
- 不把 `PaymentSpec` / `StateMutation` / i18n / voice / 台词池等 dialogue 事务语义提到 L1。
|
||||
- 不做脚本侧动态注册原生控制流(`std::function` 无法跨 C++/Squirrel);脚本侧仍只可注册
|
||||
策略与命令 handler,控制流由 C++ 拥有。
|
||||
- 阶段 1、2 不新增任何面向玩家的能力,只做架构收敛。
|
||||
- 不把 `action::ActionTimeline` 的语义轨道采样并入本模块(动作内采样器与场景级序列是
|
||||
两个粒度,各自保留)。
|
||||
|
||||
## 11. 待决问题(实施前需定)
|
||||
|
||||
1. **payload 的值类型**:建议统一用 canonical `eve::Value::Object`(与 `CommandRequest` 一致)。
|
||||
代价是 `Node::arguments` 今天存 `StateValue`,payload 化后需要在边界转换;
|
||||
必须用 §5.5 的双重重载与等价测试证明零行为变化。
|
||||
2. **`pool` 块的处理深度**:本设计取「阶段 1 共用 L1 词法与顶层块切分,池语法解析仍归
|
||||
dialogue」;完整方案是 `BlockKindRegistry` 让块类型成为注册项(阶段 4)。
|
||||
若实施中发现共享词法不足以表达池语法的行敏感属性(`parseAttrs` 用 `cur().line` 判定,
|
||||
`DnutParser.cpp:336-337`),需要在阶段 1 就引入 `BlockKindRegistry`。
|
||||
3. **`GROUP` 取值**:推荐不声明 `GROUP`(与 `rpg`/`dialogue` 一致,只随完整构建进入);
|
||||
备选 `minimal 2d 3d web`(每档都编入)。以 `scripts/profile_matrix.py` 输出取证后定。
|
||||
4. **`SCRIPT` 面的范围**:推荐阶段 1 就声明 `SCRIPT DnutInterpreter SLOT dnutInterpreter`
|
||||
并只暴露只读/装载绑定,步骤与块的脚本侧注册留到阶段 4;若认为这构成未使用的门面,
|
||||
可先 LIB-only,但后续必须补一次「加 SCRIPT」的接口变更。
|
||||
5. **命名空间与类型名**:模块名 `dnut_interpreter` 按惯例命名空间同名
|
||||
(`eve::dnut_interpreter`),但较长。候选 `eve::dnut`。需与 `eve::EventSequence`
|
||||
(`common/EventSequence.h`,事件流内序号,123 处引用)在阅读上明确区分——
|
||||
本设计不使用 `EventSequence`/`Sequence` 作为模块名。
|
||||
|
||||
## 12. ADR 信号
|
||||
|
||||
本设计触及持久架构面:**模块新增**(L1 `dnut_interpreter`)、**owner 变更**
|
||||
(`.dnut` 语法、资产模型、序列游标与命令派发的 owner 从 L6 移到 L1)、
|
||||
**公共契约变更**(`ConversationAsset`/`ConversationRunner` 被取代)、
|
||||
**依赖方向变更**(`dialogue` → `dnut_interpreter`、`rpg` → `dnut_interpreter`)、
|
||||
**源码真相源迁移**(语言前端与作者反射从 dialogue 迁到 dnut_interpreter)。
|
||||
实施完成后应补一条 ADR,并在 baseline 同步时回答:
|
||||
`action` 是否仍是「单动作生命周期」的唯一 owner(本设计回答:是),
|
||||
以及 `dnut_interpreter` 是否同时承担了「语言」与「运行时」两个职责而需要再切分(待验证)。
|
||||
@@ -68,10 +68,11 @@ WARN,不自动改写。
|
||||
(best-effort,保留相对路径便于溯源)。
|
||||
- **模块头文件**:按 `cmake/module_manifest.cmake` 的 `EVE_ENABLED_MODULES` 自动导出
|
||||
当前构建启用的模块,SDK API 面与目标运行时一致,不再手维护白名单。
|
||||
- **可用性测试**(`test-sdk.sh`,桌面三端硬门槛):SDK 布局 + 精确版本断言 +
|
||||
- **可用性测试**(`test-sdk.sh`,五平台硬门槛):SDK 布局 + 精确版本断言 +
|
||||
`eve zip`/`eve package` + **C++ 插件编译**(`find_package(EVEngine)` +
|
||||
`add_eve_plugin()`);Android 额外跑模板 APK 组装冒烟
|
||||
(`gradlew :app:assembleDebug`)。
|
||||
(`gradlew :app:assembleDebug`),iOS 额外校验 device app bundle、Mach-O 可执行文件和
|
||||
arm64 slice。移动端所需工具链缺失也是硬失败,不得跳过。
|
||||
- **完整性**:publish 前逐个校验 zip 的 `share/eve/VERSION` == tag,并生成
|
||||
`SHA256SUMS` 随 SDK 一起上传(可选开启 Artifact Attestations)。
|
||||
|
||||
@@ -90,13 +91,14 @@ SDK 上传到 Release 之后,`sdk-release.yml` 继续以“用户机器上什
|
||||
linux 主机出 win32 包,macosx 主机出 win32 + linux 包;产物上传后由
|
||||
`run-win32-cross` / `run-linux-cross` 在**目标平台**(SwiftShader /
|
||||
Lavapipe 软件 Vulkan)真实运行并 grep 标记。
|
||||
- **Android 模拟器**(`run-android`,软门槛,best-effort):APK 装进 arm64
|
||||
模拟器(macOS arm64 runner)启动 `EVEngineActivity`,logcat grep 标记;
|
||||
连续稳定后再转硬门槛。
|
||||
- **iOS 模拟器**(`ios-sim`,软门槛,best-effort):从 release 源码构建
|
||||
- **Android 模拟器**(`run-android`,硬门槛):收集 `consumer-win32`、
|
||||
`consumer-linux`、`consumer-macosx` 使用真实 release Android SDK 各自产出的三份
|
||||
APK,逐个装进 arm64 模拟器(macOS arm64 runner),启动 `EVEngineActivity` 并要求
|
||||
每份 APK 的 logcat 都出现启动标记;缺少任一产物或任一运行失败都会阻止发布。
|
||||
- **iOS 模拟器**(`ios-sim`,硬门槛):从 release 源码构建
|
||||
iphonesimulator 游戏 app(`make build/ios-sim-debug`,无需签名),simctl
|
||||
安装启动后 grep 标记。发布版 iOS SDK 是 device app,无法在模拟器跑,
|
||||
因此这步是“引擎在 iOS 上能跑游戏”的源码级验证。
|
||||
因此这步与 iOS SDK 本体的 bundle/arm64 检查共同构成发布门禁。
|
||||
|
||||
消费侧依赖的产物特性:win32 SDK 的 `bin/` 内置 `vulkan-1.dll` + VC 运行库
|
||||
(`cmake/win32_bundle_runtime.cmake`),所以任意主机都能打出自包含的 win32
|
||||
|
||||
@@ -17,20 +17,81 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
并可附带一个 `ActionTimeline`:
|
||||
|
||||
- schema id:`eve.action.timeline`
|
||||
- 当前版本:`3`;v1/v2 资产在解码候选中迁移,保存时统一规范化为 v3
|
||||
- 当前版本:`4`;v1-v3 资产在解码候选中迁移,保存时统一规范化为 v4
|
||||
- 动作总时长必须精确等于 `windup + active + recover`
|
||||
- 轨道类型:Animation、Gameplay、Effect、Audio、Camera、Movement、Tag、Custom
|
||||
- `ActionNotify` 表示单点事件;`ActionNotifyState` 表示有明确 Enter/Exit 的区间
|
||||
- `ActionNotify` 表示单点事件;`ActionNotifyState` 表示有明确 Enter/Exit 的区间;两者的
|
||||
`enabled` 是持久权威状态,禁用项仍可编辑但不投射运行时事件或 Active Block
|
||||
- 事件按时间、边界种类、轨道 id、条目 id 稳定排序;静音轨道不投射事件
|
||||
- `ActionRuntime::advance` 使用注入的确定性 `Duration` 采样 `(previous, current]`,首次启动可包含零时刻
|
||||
- `ActionRuntime::advance` 同时返回当前激活区间的拥有型 `ActionActiveBlock`,包括局部时间与区间时长
|
||||
- Montage 全局设置通过单次编辑事务发布;已准备的 `MontagePlayer` 可在不重建 Clip
|
||||
拓扑或重置播放时间的前提下同步层、Foot IK、混合和 Root Motion 遮罩
|
||||
|
||||
投射结果拥有自己的 payload,不借用热重载定义中的指针。下游适配器根据 `type` 和 payload 处理伤害窗口、
|
||||
输入缓冲、碰撞忽略、运动扭曲、VFX、音频或镜头,不需要 `action` 依赖这些提供方。
|
||||
|
||||
`ActionBlockRuntime` 将同一个 advance 依次投递为 Enter/单点事件、持续 Update,并按 execution 保存最后一份
|
||||
激活投影。动作被打断时,宿主调用 `interrupt`,它会为仍激活的区间补齐 Exit;已经成功退出的条目立即从
|
||||
清理集合删除,因此失败重试不会重复退出它们。编辑器跳转使用 `sample`,该路径强制设置 preview/scrubbing
|
||||
上下文,handler 契约禁止产生持久副作用。handler 由 `ActionNotifyRegistry` 共享持有,回调同步发生在 owner
|
||||
thread,不得保存借用参数或重入同一个 `ActionBlockRuntime`。
|
||||
|
||||
Audio、VFX 与 Prefab Spawn 共用 `ActionSpatialBinding` 解析 payload 中的空间字段:`attachment` 支持
|
||||
`follow_target`、`follow_position_only`、`world_transform_at_start`;`spatialTarget` 选择 `source` 或
|
||||
`target`,后者可配 `targetIndex`;`bone` 保存稳定骨骼/插槽名;`positionOffset`、
|
||||
`rotationOffsetDegrees`、`scale` 是三个有限数值组成的数组。scale 必须逐轴大于零。该值只保存拥有型数据,
|
||||
实体 handle 与骨骼名由具体表现模块在每次同步调用时解析,不把 Scene/Bone 裸指针跨帧带入 action 核心。
|
||||
|
||||
`presentation:vfx-state` 已由 Particles provider 创建并独占真实 `ParticleEffect`,在 Enter 启动、Update 同步偏移。
|
||||
`ActionVfxBinding` 统一校验 URI、空间字段、`clipStartTime/clipEndTime`、`playbackRateSynced` 和
|
||||
`stopBehavior`;区间裁剪相对 block duration 设置播放拉伸,并在 `playbackRateSynced` 启用时乘以
|
||||
`ActionNotifyContext.playbackRate`。Exit/interrupt 的 `clear_immediately` 立即清空,
|
||||
`stop_emitting` 则停止新粒子并保留残余粒子至自然消散,之后由 handler 回收。`presentation:audio-state`
|
||||
由 Audio provider 独占 `Source`,并用 `ref`
|
||||
保持 `ResourceManager` 缓存产生的 `SoundData`,因此预览期间卸载缓存也不会留下悬空指针。Audio 运行时与预览共用
|
||||
`ActionAudioBinding` 的 URI、`randomUris`、volume、pitch、`randomPitchOffset`、`spatialBlend`、
|
||||
`minDistance/maxDistance`、looping 和空间字段校验,不存在两套 payload 解释。随机池和音高扰动使用
|
||||
execution id、item id 派生的稳定 seed;同一次执行与编辑器刷新可重现,不读取全局随机状态。随机池存在时
|
||||
覆盖基础 URI,基础 URI 仍作为池为空时的回退。`spatialBlend=0` 使用 listener-relative 2D Source,正值使用
|
||||
世界空间 Source;3D Source 将校验后的最小/最大衰减距离直接交给 OpenAL。单点
|
||||
和区间音频都校验 `fadeOutOnExit/fadeOutDuration`;区间正常离开时,handler 以注入的 action `Duration`
|
||||
逐帧降低 Source gain,到期后停止并释放,不使用墙上时钟。关闭淡出时则在 Exit 立即停止。
|
||||
执行被 interrupt 后不会再有该 execution 的时间推进,因此 `ActionNotifyContext.interrupted` 使 Source 立即停止,
|
||||
避免把无法完成的淡出留在 handler 中;这一路径是显式可观察的生命周期降级,不伪装成完整淡出。
|
||||
`presentation:vfx` 要求正的有限 `lifetimeSeconds`,单点
|
||||
`presentation:audio` 以解码片段时长除以 pitch 得到截止时间且禁止 looping;需要循环的资源必须使用
|
||||
NotifyState。两类单点资源在触发后保留,由 handler 按 action `Duration` 确定性过期并停止销毁,
|
||||
不读取墙上时钟。provider 是可选模块能力:未创建对应模块时 descriptor 仍可编辑,但运行 dispatch
|
||||
明确返回 handler `NotFound`。Scene 通过 `IEntitySpatialProvider` 将 generation-qualified `SceneObject` handle
|
||||
解析为当前世界姿态;`follow_target` 每次 Update 同步完整姿态,`follow_position_only` 只同步位置,
|
||||
`world_transform_at_start` 固定 Enter 时的姿态。没有任何 provider 接受该实体时返回 `NotFound`;Scene 根节点
|
||||
收到非空 `bone` 时由宿主在同步 `ActionNotifyContext` 中提供借用的 `IAttachmentPointSource`;Avatar 已实现该
|
||||
接口并按语义骨骼名或原生骨骼名采样最新姿态。骨骼局部 position offset 由该接口转换到世界坐标;上下文引用
|
||||
只在本次 dispatch 有效,handler 不保存指针。没有 attachment source 时返回 `Unsupported`,不会静默退化到对象原点。
|
||||
|
||||
Prefab Spawn 使用内置状态类型 `gameplay:prefab-spawn`。`ActionPrefabSpawnBinding` 先原子校验非空
|
||||
`uri`、空间字段与 `lifecycle`;生命周期可取 `recycle_on_block_exit`(默认)、`custom_duration` 或
|
||||
`independent`。`custom_duration` 还必须提供正的有限 `customDurationSeconds`。SceneLoader 的可选 provider
|
||||
通过 `Model3D` 解码 URI,并为每个网格创建真实 `Renderable3D`;同 URI 回收后复用 ECS 实体和 GPU 资源,
|
||||
但逻辑 lease 每次回收都会递增 generation,旧 lease 不能再更新新实例。运行时在 Enter 解析骨骼/目标世界姿态,
|
||||
Update 按 attachment 模式同步,Exit/interrupt 对 `recycle_on_block_exit` 隐藏回池;`custom_duration` 按注入的
|
||||
action `Duration` 维护到期,`independent` 则脱离动作块并保持可见。provider 缺失时 descriptor 仍可编辑,
|
||||
dispatch 明确返回 `NotFound`。独立实例通过 `IActionPrefabInstances` 暴露拥有型检查记录和 generation-qualified
|
||||
handle;外部系统可调用 `recycleIndependent`,回收后旧 handle 稳定返回 `NotFound`,不能越权回收仍由动作块
|
||||
持有的实例。实例池随 SceneLoader 启停并撤销 capability,registry handler 不再决定独立实例寿命。
|
||||
预览 `sample` 仍只校验且不生成 ECS/GPU 副作用。SceneLoader 另以
|
||||
`IActionPreviewSinkProvider` 提供 Editor 专用预览层:`prepare` 在不可见的独立池中加载真实
|
||||
`Renderable3D`,`present` 才原子切换可见实例;跳帧、离开区间或销毁编辑器都会立即回收预览实例,
|
||||
并且 `custom_duration` 按 block local time 隐藏。该池与运行时 Prefab 池分离,不会把预览对象变成
|
||||
`independent` 游戏实例。多个 Audio/VFX/Camera/Scene provider 可按 listener 顺序组合预览层,
|
||||
不存在单一 factory 抢占其它表现模块的问题。
|
||||
|
||||
## 持久化约定
|
||||
|
||||
编码器接受 v1-v3,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略,
|
||||
规范化保存只写出 v3 已知字段。v3 增加多动画分段的源裁剪、混合曲线、物理分段、循环/层/Foot IK 和 Root Motion 遮罩。
|
||||
编码器接受 v1-v4,未知更高版本返回结构化 `Result` 错误;同版本未知字段在读取时忽略,
|
||||
规范化保存只写出 v4 已知字段。v3 增加多动画分段的源裁剪、混合曲线、物理分段、循环/层/Foot IK 和 Root Motion 遮罩;
|
||||
v4 为 Notify/NotifyState 增加 `enabled`,旧版本迁移时默认 `true`。
|
||||
根对象的 `metadata` 与每个事件的 `payload` 是扩展数据的正式位置。
|
||||
解码先构造并完整校验候选值,再发布结果,不会留下部分修改。
|
||||
|
||||
@@ -51,13 +112,38 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
- 新建语义轨道、Notify 和 NotifyState
|
||||
- 轨道锁定保护、静音切换、条目移动和删除
|
||||
- 框选、多选、复制、粘贴以及稳定的新 id
|
||||
- 显式共享的深拷贝剪贴板支持跨文档粘贴选中区块与整条轨道;目标轨道可重定向,轨道和全部子条目会避开现有 id
|
||||
- 每次修改通过 `action.timeline.replace` 可逆 DomainOperation 提交
|
||||
- Undo/Redo 通过 `LocalWorldAuthority` 与 `LocalTransactionBackend` 执行,不维护第二份资产状态
|
||||
- Play/Pause/Seek/Update 预览传输,以及本帧跨越的拥有型事件列表
|
||||
- Stop/首帧/末帧与 signed frame-step;显式 frame rate 在纳秒域确定性取整并夹取到时间轴范围
|
||||
- 时间标尺、帧/时间吸附、围绕锚点缩放与有界平移
|
||||
- 轨道增删改名、静音和锁定,以及动画分段的 Trim In/Out 和 Blend Curve Inspector
|
||||
- 框选/追加多选后拖动整体,或用 `Shift+[` / `Shift+]` 对齐开始/结束;锁定轨道会使整个批处理原子失败
|
||||
- 多选 Inspector 编辑外层 Start/End 时按相对位置等比缩放全部条目,精确到纳秒并作为一次可撤销事务提交
|
||||
- Audio State 区块通过可选 Audio provider 绘制真实解码 PCM 的有界 min/max 波形;pitch 与 looping 映射和运行时一致,缺少 provider 时布局显式报告不可用
|
||||
- `presentation:parameter-curve` 区块直接绘制 Step/Linear/Cubic 曲线和关键点;Inspector 与 C++/Squirrel API 对关键帧的增删改都走同一个可撤销事务,并保留未知扩展字段
|
||||
- 每个区块从 Notify Registry 和当前 payload 投射拥有型显示名、资源/参数摘要与稳定视觉类型;示例据此绘制
|
||||
Audio 波形、VFX 闪光、Prefab、Camera、Hitbox、防御及移动等微型图形,并在 Inspector 显示资源文件名或
|
||||
关键参数。视觉投影不写回 timeline schema,插件类型缺少专用视觉时明确使用 Custom 回退
|
||||
|
||||
运行宿主由 `ActionRuntime` 唯一负责 gameplay Enter/Update/Exit,`MontagePlayer` 只消费其 advance 并驱动多 Clip 姿态。
|
||||
组合预览 Controller 对 stop、逐帧步进和末帧跳转同样先让全部 Provider prepare,成功后才移动 cursor 并暂停,
|
||||
因此资源准备失败不会造成视口和时间轴位置分离。示例的 transport 使用紧凑语义图标工具条提供首帧、前一帧、
|
||||
播放、停止、后一帧和末帧操作。
|
||||
|
||||
`ActionTimelineClipboard` 只拥有值拷贝,不借用源文档对象。多个打开文档必须显式共享同一个 clipboard;Squirrel
|
||||
模块创建的编辑器默认共享模块级 clipboard。粘贴整轨和跨文档重定向粘贴都先构造、验证完整候选,再通过单次
|
||||
`action.timeline.replace` 事务发布;目标轨道锁定、时间越界或 schema 非法时不会留下部分内容。clipboard 与编辑器
|
||||
一样只允许 owner thread 使用,不提供隐式进程全局可变状态。
|
||||
|
||||
`ActionTimelineDocumentWorkspace` 在 `DocumentService` 上维护有序多标签文档,并让所有标签共享上述 clipboard。
|
||||
打开同一 `AssetGuid` 会激活现有标签而不复制第二份文档;Save 先把 canonical target 的完整 snapshot 写入文档
|
||||
工作修订,再使用 revision/hash CAS 保存。未保存标签默认拒绝关闭,只有显式 `Discard` 才丢弃内存修改。外部文件
|
||||
变化在干净标签上整体重载并清空旧 Undo/Redo、选择与 transport;本地脏修改与外部变化相遇时进入 Conflict,
|
||||
保留本地权威内容。workspace 与其借用的 `DocumentService` 均只允许 owner thread 使用,后者必须比 workspace 长寿。
|
||||
|
||||
运行宿主由 `ActionRuntime` 唯一负责 gameplay 时间与激活状态,`ActionBlockRuntime` 路由 Enter/Update/Exit,
|
||||
`MontagePlayer` 只消费同一份 advance 中的事件和 `ActionActiveBlock` 并驱动多 Clip 姿态,不再自行计算第二份激活状态。
|
||||
`MontageCoordinator` 每层固定两个槽位做 ping-pong 混合,generation handle 在槽位复用后拒绝旧引用。运行时支持跳转、物理分段同步、
|
||||
暂停、中断混出、低帧率边界扫描和循环尾/头两段扫描。Clip 热更新先完整验证候选,再原子替换并保留当前时间、权重与执行身份。
|
||||
|
||||
@@ -70,7 +156,7 @@ EVEngine 的差异是:运行时和编辑器共享同一个版本化资产模
|
||||
|
||||
## 后续表现层扩展点
|
||||
|
||||
3D 模型预览、Root Motion 轨迹、波形和特效缩略图应实现为消费上述公开状态的面板适配器;
|
||||
特效缩略图等后续投影应实现为消费上述公开状态的面板适配器;
|
||||
它们不能成为动作资产 schema 的第二来源。新增内置 Notify 类型时,只注册稳定 `LogicalId` 及其 payload
|
||||
契约,不在核心层加入表现模块 include。
|
||||
# 战斗系统与动作编辑器
|
||||
@@ -116,11 +202,54 @@ Stagger、Flinch、None,Poise 恢复是显式且有界的操作。该路径不
|
||||
|
||||
`action::ActionNotifyRegistry` 是 Notify 类型契约的唯一权威所有者,同一份 descriptor 同时供
|
||||
Editor 列表、payload 校验与运行时路由使用。内建契约覆盖 Gameplay Event、Damage、VFX、Audio、
|
||||
Camera,以及 Hitbox、无敌、Combo、碰撞忽略和 Root Motion 区间;区间类型明确校验 Enter/Exit,
|
||||
Camera、连续参数曲线,以及 Hitbox、无敌、Combo、碰撞忽略和 Root Motion 区间;区间类型明确校验 Enter/Exit,
|
||||
不会被当成单点事件误用。
|
||||
|
||||
`presentation:camera` 使用类型化 `ActionCameraCueBinding` 校验 LogicalId cue、位置/旋转/FOV 幅度、正时长和
|
||||
32 位 seed。内建 handler 只处理瞬时事件,并通过可选 `IActionCameraCueSink` 路由;Camera 模块的
|
||||
`CameraController` 可显式注册为 sink,把 cue 变成真实、确定性衰减的 Impulse,析构时自动注销。缺少目标或
|
||||
payload 非法均在修改相机前失败。
|
||||
|
||||
`combat:damage` 使用类型化 `ActionDamageBinding` 校验 Gameplay Tag 伤害类型、生命/韧性伤害、
|
||||
目标索引与击退向量。内建 handler 只处理瞬时事件,并将 generation-qualified 目标路由给可选
|
||||
`IActionDamageSink`;Combat 模块的 `CombatActionDamageSink` 由游戏注入 ECS 句柄到 `CombatState` 的同步解析,
|
||||
再通过唯一的 `DamageRuntime` 原子提交生命、韧性、受击反应和击退结果。适配器需显式启用,析构自动
|
||||
注销;缺少 sink、目标或解析器时返回可观测 `NotFound`,不会部分修改战斗状态。
|
||||
|
||||
`gameplay:event` 使用类型化 `ActionGameplayEventBinding` 校验 Gameplay Tag topic、可选目标索引和拥有型
|
||||
`data`。Action 核心只同步路由给可选 `IActionGameplayEventSink`;独立 `action_game_event` 卫星模块的
|
||||
`ActionGameEventSink` 将 ECS 句柄解析为稳定 `SubjectRef`,再追加到游戏拥有的 `GameEventLog`。事件日志仍是
|
||||
顺序、UUIDv7 身份、快照与恢复的唯一权威;适配器不保存另一份事件集合。持久 payload 的 schema id 为
|
||||
`action:gameplay-event-v1`、版本为 1;`data` 整体按 `Value` 规则保留,未知领域字段由其上层 schema 解释,日志不丢弃或改写。
|
||||
缺少 sink、无法解析来源/目标、无 UUID 生成器或 append 失败都会在事件可观测前返回结构化错误。
|
||||
|
||||
Hitbox、无敌、Combo 和碰撞忽略共用类型化 `ActionStateWindowBinding` 与成对 Enter/Exit handler,但可由
|
||||
不同领域 Sink 按 kind 分别拥有。Combat 的 `CombatActionWindowState` 目前权威维护 Hitbox 和无敌窗口:
|
||||
活动 key 由 `ActionExecutionId + itemId` 组成,重叠无敌窗口不会被一次 Exit 误清,活动 Hitbox 可按稳定
|
||||
`SubjectRef` 查询。Enter 时从 generation-qualified ECS 句柄解析并保存拥有型 SubjectRef,Exit 只按稳定 key
|
||||
清理,因此支持“实体先销毁”和“窗口先退出”两种顺序;`ActionBlockRuntime::interrupt` 会派发配对 Exit。
|
||||
`CombatActionDamageSink` 可借用该窗口状态,目标处于任一无敌窗口时返回可观测 `NoOp`,不改变生命、韧性或留下过期 Outcome。
|
||||
|
||||
Combo 窗口由独立 `action_input` 卫星的 `ActionComboWindowState` 拥有。它不假设全局输入总线,也不直接
|
||||
触发下一个 Action;玩家、AI 或脚本适配器先把设备输入映射为例如 `attack.heavy` 的语义字符串,再查询
|
||||
`availableInputs` 或 `match`。匹配结果拥有 execution、item、subject 和 input,重叠窗口按 execution/item key
|
||||
确定性选择;退出和 interrupt 不需要重新解析已销毁的 ECS 实体。这使玩家与 AI 共用同一份窗口权威,而下一个
|
||||
Action 的请求、条件和成本仍由 `ActionRuntime` 提交。
|
||||
|
||||
碰撞忽略窗口由 `physics_action` 卫星的 `ActionCollisionIgnoreState` 投影到真实 `World3D` body-pair
|
||||
override。玩法 resolver 把 source/target 与 `channel` 解析为两个 `PhysicsLink`;适配器只跨帧保存 world/body
|
||||
generation handle,并用 execution/item key 对同一碰撞对引用计数。最后一个窗口退出时恢复进入前的开关状态,
|
||||
因此不会错误开启原本已被其他系统禁用的碰撞。实体失效不影响退出;body 或 world 先销毁时,物理 authority
|
||||
已经同步删除 override,弱生命周期令适配器只清除自身投影而不解引用悬空对象。
|
||||
|
||||
连续参数曲线的 payload 使用稳定 target、replace/add/multiply 操作和 2—1024 个严格递增的归一化关键帧;
|
||||
端点固定在 0 与 1,运行时按 execution/item 身份成对派发 Begin/Update/End。可选
|
||||
`IActionParameterSink` 是目标领域的所有权边界;内建 Audio sink 实现 `audio:master-volume`,以候选状态原子组合
|
||||
重叠曲线,并在最后一条曲线结束或中断时恢复基线。缺少 sink、非法数值或目标不受支持均返回可观测失败。
|
||||
|
||||
扩展类型必须使用与 Timeline 一致的 `namespace:name` LogicalId,并声明必需 payload 字段。路由前先完整校验,缺少
|
||||
handler 与 handler 返回的失败都是可观测 `Result`;注册表以 `shared_ptr` 持有 handler,所有方法
|
||||
handler 与 handler 返回的失败都是可观测 `Result`;注册表以 `shared_ptr` 持有 handler,每次 apply 在
|
||||
边界事件和持续 Update 之后按类型字典序维护 handler,共享实例只执行一次,失败向上传播。所有方法
|
||||
限 owner thread 调用,插件卸载前必须先显式注销,不保留事件或 context 的跨帧借用引用。
|
||||
|
||||
## 可交互 Timeline Widget
|
||||
@@ -134,13 +263,29 @@ Pointer Up 才通过 `ActionTimelineEditor` 产生一个可撤销事务;Cancel
|
||||
时间、LogicalId、JSON payload 和 Notify descriptor,然后一次替换条目,不会在后续字段失败时留下
|
||||
部分时间修改。
|
||||
|
||||
`ActionTimelinePayloadEditor` 是类型化 Inspector 与脚本的窄适配器。它只借用 owner-thread 的
|
||||
`ActionTimelineEditor` 和 `ActionNotifyRegistry`,不保存第二份 Timeline;partial patch 先合并到拥有型
|
||||
payload 副本并保留未知字段,再让注册表校验完整事件,最后通过权威 `updateItem` 提交一个事务。
|
||||
因此字段级 UI、Advanced JSON、Undo/Redo 和运行时预览都观察同一个 revision,非法候选不会发布。
|
||||
VFX State 的 `fitBlockToClip` 与 `fitClipToBlock` 也在该适配器内同时读取块区间和类型化 payload,分别
|
||||
原子修改块末端或 `clipEndTime`;它们不通过两个字段编辑拼接事务,因此失败和 Undo 都不会留下半更新状态。
|
||||
资源自然时长由 action 层声明的可选 `IActionVfxDurationProvider` 提供,particles 实现只返回 owning
|
||||
`Duration`,不向 editor 暴露或保留 ParticleEffect 指针。`fitClipToNaturalDuration` 对 provider 存在与
|
||||
缺失分别测试;无界 emitter 返回 Unsupported,编辑器不缓存资源时长这份可变派生数据。
|
||||
|
||||
Widget 还提供 Copy/Paste/Delete/Undo/Redo/PlayPause 的右键菜单描述和快捷键路由,粘贴以播放头
|
||||
为锚点;新建条目只能从注册表中选择形状匹配的类型,并在提交前校验必需 payload。
|
||||
|
||||
Physical Sections 的分割边界由 `ActionTimeline::splitTimestamps` 独占,不从动画 Section 或 Gameplay
|
||||
Track 反推。`ActionTimelineEditor::addSectionSplit/setSectionSplit/removeSectionSplit` 复制候选 Timeline、
|
||||
排序并走同一个 replace 事务;Timeline 校验器最终保证边界严格位于 `(0, duration)` 且互不重复。
|
||||
脚本宿主在事务成功后同步其运行时 Timeline,因此跳转、Section 进度、动态时长同步和编辑器磁吸不会分叉。
|
||||
|
||||
## 事务式动作预览
|
||||
|
||||
`editor::ActionPreviewController` 通过 `action::IActionPreviewSink` 将权威播放头、动画资源 URI、
|
||||
VFX/Audio/Camera cue 和状态边界组合成拥有型 `ActionPreviewFrame`。宿主先在 `prepare` 中验证并暂存
|
||||
VFX/Audio/Camera cue、状态边界和当前位置的 `ActionActiveBlock` 组合成拥有型 `ActionPreviewFrame`。
|
||||
宿主先在 `prepare` 中验证并暂存
|
||||
一帧需要的资源,时间线仍未改变;只有准备成功后才提交播放头并调用不可失败的 `present`,因此资源失败
|
||||
不会造成画面和时间线各走一半。若准备后发现 revision 或播放头已变化,控制器调用
|
||||
`discardPrepared` 并返回结构化冲突。
|
||||
@@ -150,6 +295,28 @@ Root Motion 由可选 `action::IActionRootMotionSource` 提供。缺少 Provider
|
||||
`IEditorOverlay` 绘制路径。Controller、Editor、Sink 和 Provider 都由宿主拥有并保证生命周期,
|
||||
接口仅允许 owner thread 调用,不跨帧保留输入引用,也不在锁内调用未知回调。
|
||||
|
||||
脚本 `ActionTimelineEditor` 会组合当前已注册的全部 `IActionPreviewSinkProvider`,并让
|
||||
`seekSeconds`、`seekX`、`update` 统一经过 Controller;`hasPreviewHost()` 可观察是否存在 3D/音频等
|
||||
预览层,`refreshPreview()` 用于资产参数事务提交后在当前播放头重建投影。没有 provider 的裁剪配置
|
||||
仍保留纯时间轴编辑,但该状态不会伪装成已经具有表现预览。
|
||||
|
||||
Audio provider 的预览层对 `presentation:audio-state` 按 item id 持有真实 `Source`:连续
|
||||
Advance 且 payload 未变时不重启,Seek/Refresh 依据 block local time 和 pitch 重建并定位,
|
||||
离开区间或销毁 sink 时立即停止。单点 `presentation:audio` 在 `present` 后保留至自然停止;
|
||||
`prepare` 任一解码或校验失败都丢弃候选资源,不打断已提交的当前播放。
|
||||
|
||||
Particles provider 的预览层使用与运行时分离的真实 `ParticleEffect`。Seek/Refresh 会在不可见
|
||||
候选实例上用固定 1/60 秒步长从稳定 seed 重模拟到 `clipStart + progress * clipDuration`,
|
||||
`present` 才替换当前实例。连续 Advance 且 payload/duration 未变时复用实例并只模拟增量;倒退、
|
||||
参数或 URI 改变时重建。预览粒子保持 pause,不被全局 Particles tick 重复推进;单点 VFX 按时间线
|
||||
`lifetimeSeconds` 保留并回收。该 CPU 预览对固定 seed 与相同输入 bit-stable;GPU 渲染仍遵循粒子后端的
|
||||
tolerance 契约。
|
||||
|
||||
其可选 Montage runtime 只把 registry 中实际安装了 handler 的事件和 active blocks 交给
|
||||
`ActionBlockRuntime`,用于驱动当前编辑器进程拥有的 Audio/VFX/Prefab 表现并维护到期资源;没有安装
|
||||
handler 的 gameplay 语义仍完整保留在 `MontageAdvance` 中交给项目宿主消费,编辑器不会伪装执行伤害、
|
||||
输入或碰撞规则。跳转、循环重启、取消和脚本对象释放都会对已路由 block 补齐 interrupt 清理。
|
||||
|
||||
完整 3D 配置可使用 `editor::AnimationRootMotionPreviewSource`。它按 URI 在
|
||||
`animation::AnimClipRegistry` 中即时解析唯一 Clip,并采样指定 root bone 的局部位移;不缓存 Clip
|
||||
指针,所以 Clip 先销毁时下一次采样得到明确 NotFound,Clip 热重载则沿用 Registry 维护的稳定实例。
|
||||
@@ -184,6 +351,15 @@ Root Motion 由可选 `action::IActionRootMotionSource` 提供。缺少 Provider
|
||||
意图源失败时不会改变 AbilityRuntime;意图一旦由 Source 交付便按 exactly-once 消费,即使激活被拒绝
|
||||
也不会隐式重试。接口不保留 tick、request 或 ECS 裸指针,调用时不持有引擎锁。
|
||||
|
||||
`combat::CombatLocomotionRuntime` 是玩家方向输入与导航 steering 共用的水平移动 owner。它按稳定 SubjectRef
|
||||
保存位置、速度、朝向、加速度、速度上限和可选目标;`setMoveIntent` 会显式接管并清除旧导航目标。
|
||||
`ICombatNavigationProvider` 只为当前候选状态计算方向和速度比例,既不拥有角色也不直接写位置;内置
|
||||
`DirectCombatNavigationProvider` 为无障碍 arena 提供直线导航;可裁剪的 `combat_navigation` 卫星用
|
||||
`PathfinderCombatNavigationProvider` 接入 canonical map Pathfinder,并按每步最新地图状态重新规划。`advance` 只使用
|
||||
注入的 `SimulationStep`,在副本中完成全体 steering、加速和积分后一次发布;任一 Provider 失败、返回 NaN 或
|
||||
非法比例时整帧不移动。Provider 缺失、移除后仍有活动目标、重复注册和 stale Subject 均为结构化错误;跨越
|
||||
目标的步长会夹取到目标并发出 owning Arrived 事件,不产生来回振荡。
|
||||
|
||||
## 战斗属性、再生与事件
|
||||
|
||||
`combat::CombatAttributeRuntime` 在通用 `attributes::AttributeSet` 之上提供 Gameplay Tag 寻址的 Health、
|
||||
@@ -210,12 +386,12 @@ Mana、Stamina 等战斗属性。每个定义声明初值、上下界和每秒
|
||||
实例策略、20 项能力、Tag 属性/自动再生/变化事件、Buff/Debuff、玩家/AI Controller、对象池、Tag ID
|
||||
生成器、Animation Clip Editor 与自定义 Property Drawer。
|
||||
|
||||
按“运行时架构 45、编辑器工作流 25、配套系统 20、开箱内容 10”计权,本实现当前为 **90/100**:
|
||||
按“运行时架构 45、编辑器工作流 25、配套系统 20、开箱内容 10”计权,本实现当前为 **92/100**:
|
||||
|
||||
- 运行时架构 43/45:Tag、Ability、Action、冷却/实例/互斥、Buff 生命周期、属性再生/事件、Damage/Poise、
|
||||
Projectile 均有引擎原生实现;扣分项是 AbilityDefinition 尚无独立的版本化磁盘 schema。
|
||||
- 编辑器工作流 23/25:动作 Timeline、Notify/NotifyState、Tag Picker、Inspector、事务 Undo/Redo、3D 预览与
|
||||
Root Motion 已覆盖;扣分项是 VFX/Audio 波形与缩略图仍由宿主实现。
|
||||
- 编辑器工作流 25/25:动作 Timeline、Notify/NotifyState、Tag Picker、Inspector、事务 Undo/Redo、3D 预览、
|
||||
Root Motion、真实 Audio/VFX 预览、Audio State 波形及连续参数曲线已覆盖。
|
||||
- 配套系统 18/20:玩家 FIFO 与 AI Source 共用控制层、Projectile generation pool、稳定 Tag ID 已覆盖;
|
||||
扣分项是没有绑定某个角色运动/导航实现的成品 Controller。
|
||||
- 开箱内容 6/10:提供 20 个可注册能力原型,并随测试分发 KayKit CC0 角色、武器及 5 组动画库的最小
|
||||
@@ -223,14 +399,28 @@ Mana、Stamina 等战斗属性。每个定义声明初值、上下界和每秒
|
||||
|
||||
因此“框架能力追到 90%”已经达到;若按购买后直接拖入场景的成品体验衡量,仍约为 80%—85%。现已提供
|
||||
`examples/combat-action-editor`,用真实 KayKit 角色、武器和攻击动画演示四面板动作编辑、拖拽、撤销与确定性预览。
|
||||
剩余工作优先级是:版本化 Ability 资产编解码与迁移、完整可玩 combat-arena、VFX/Audio 预览可视化、可选的角色移动与
|
||||
导航适配包。这些不应反向耦合进 action 核心。
|
||||
AbilityDefinition 现使用 `eve.action.ability` 持久格式:当前版本 v2 以纳秒保存 Action 三阶段与冷却,读取
|
||||
v1 时把对应毫秒字段逐项迁移并在下次保存时规范化为 v2。codec 覆盖条件树、TargetingSpec、CostSpec、
|
||||
Effect Id、metadata、嵌套 Action Timeline、实例策略、互斥组和 Gameplay Tag 触发器;所有输入先构造 owning
|
||||
候选并完成 `AbilityDefinition::validate()`,解码失败不会触碰 AbilityRuntime。未知顶层字段、窗口外旧版本和
|
||||
未来版本拒绝;已知嵌套对象中的未知字段忽略,嵌套 Timeline 继续服从自身 v4 的迁移与字段政策。20 个标准
|
||||
战斗能力均经过 `Value -> JSON -> Value -> AbilityDefinition -> AbilityRuntime` 组合测试。
|
||||
|
||||
剩余工作优先级是:把上述移动、Ability、Damage、地图导航和动画表现组合为完整可玩的 combat-arena。
|
||||
这些不应反向耦合进 action 核心。
|
||||
|
||||
`kaykitCombatAssets.importRigClipsAndDriveActionEditorPreview` 是真实资产证据:测试从官方免费 KayKit 包导入
|
||||
Knight 与单手剑,覆盖 Idle/Hit/Death/Run/Jump/Dodge/Melee/Block/Bow/Magic 动画,并用一段实际攻击动画驱动
|
||||
Timeline 编辑、事务 Undo、内置 Notify 合同校验、完整事件派发和 16 点 Root Motion 预览。夹具来源、版本、
|
||||
许可证及原始压缩包 SHA-256 记录在 `examples/combat-action-editor/assets/kaykit/README.md`;未包含付费、EXTRA 或 SOURCE 内容。
|
||||
|
||||
`MontageCoordinator` 的每个动画层可配置一份拥有型逐骨骼权重遮罩,同层的双槽交叉混合共享该
|
||||
遮罩。权重数量必须与当前 `AnimSkeleton` 一致,每项都是有限的 `[0,1]`;候选遮罩在完整校验后才
|
||||
一次替换。遮罩不持有骨架指针,也不改变 Montage 句柄代际,因此可用于上半身攻击与下半身移动的确定性合成。
|
||||
每层还可显式安装一个借用的 `IMontageRootMotionReceiver`;Coordinator 会把它同步到当前与未来的
|
||||
双槽 Player,切换动作时不需要重新解析句柄并挂接。接收器不被持有,其 owner 销毁前必须清除;清除会同步
|
||||
解绑所有存活槽。
|
||||
|
||||
`combatFramework.composesTagsPlayerAbilityBuffDamageAttributesAndProjectile` 是跨模块组合证据:同一场景内完成
|
||||
Tag 注册、玩家意图激活 Ability、Buff 生命周期、Stamina 消耗/再生、带 Action Link 的伤害以及 Projectile
|
||||
推进;各状态仍由所属模块唯一拥有。
|
||||
@@ -0,0 +1,119 @@
|
||||
# 程序化道路系统设计
|
||||
|
||||
日期:2026-09-17
|
||||
状态:MVP 已落地(`src/modules/procgen/road/`)
|
||||
存放:`docs/dev/`
|
||||
|
||||
## 目标
|
||||
|
||||
在**不使用预制道路/桥梁模型**的前提下,用数学函数生成与参考图一致的视觉层:
|
||||
|
||||
1. **几何**:多高程道路、桥面、侧栏、人行道、桥墩
|
||||
2. **逻辑贴图**:沿弧长/横向 UV 的车道线、斑马线、导向箭头
|
||||
3. **导航网络可视化**:车道中心线与路口转弯弧(青/绿叠加)
|
||||
|
||||
全部由 `SplinePath` 平行输运帧 + 截面挤出 + 图论车道图合成,运行时可烘培为 `MeshBuild`。
|
||||
|
||||
## 非目标(MVP)
|
||||
|
||||
- 完整路口 CSG 布尔融合(用路口平台 + 边端收缩近似)
|
||||
- 地形挖填 / 贴地切割
|
||||
- 真实交通仿真与 AI 寻路驱动
|
||||
- 中文「道路编辑」产品化编辑器面板(文档预留工具流,运行时先配方 + 示例)
|
||||
|
||||
## 与现有能力的关系
|
||||
|
||||
| 复用 | 角色 |
|
||||
|------|------|
|
||||
| `SplinePath` / `sampleFramesResult` | 中心线与 Frenet 式截面帧 |
|
||||
| `MeshBuild` 命名 group | `asphalt` / `curb` / `sidewalk` / `deck` / `pier` / `marking` / `nav` |
|
||||
| `MeshRecipeRegistry` | `mesh.roadNetwork` 配方入口 |
|
||||
| `TextureRecipeRegistry` | `tex.roadMarkings` 逻辑贴图 |
|
||||
| `urban` 街道 | 可选上游 2D 中心线;本系统是 3D 车道域 |
|
||||
|
||||
**不**新建顶层 `road` 模块目录;算法作为 `procgen` 宿主内的 `road/` 卫星(同 `urban/`)。
|
||||
|
||||
## 数据模型
|
||||
|
||||
```text
|
||||
RoadNetwork
|
||||
nodes[]: RoadNode { id, position, junctionRadius }
|
||||
edges[]: RoadEdge {
|
||||
id, from, to,
|
||||
controlPoints[] → SplinePath (catmullRom),
|
||||
lanesForward, lanesBackward,
|
||||
style (laneWidth, curb, sidewalk, deckThickness, pier*),
|
||||
elevated (bool | height threshold)
|
||||
}
|
||||
laneLinks[]: at a node, (inEdge, inLane) → (outEdge, outLane)
|
||||
```
|
||||
|
||||
权威所有者:`RoadNetwork` 拥有节点/边/车道连接;烘培结果是无指针快照(`MeshBuild` + `RoadOverlay` 折线)。
|
||||
|
||||
## 数学管线
|
||||
|
||||
### 1. 截面剖面 `RoadProfile`
|
||||
|
||||
在边局部 `(side, up)` 平面构造折线剖面:
|
||||
|
||||
```text
|
||||
[-W/2-sidewalk-curb … asphalt lanes … +W/2 …]
|
||||
```
|
||||
|
||||
每个区段带 material tag,挤出时写入对应 triangle group。桥面在 `up` 负向加 `deckThickness` 底板。
|
||||
|
||||
### 2. 边挤出 `RoadBake::bakeEdge`
|
||||
|
||||
1. 边控制点 → `SplinePath`,弧长均匀采样帧
|
||||
2. 在路口半径处裁剪端点参数,避免与路口平台重叠
|
||||
3. 沿帧挤出剖面(开剖面条带 + 底板可选)
|
||||
4. 若帧高度 > `pierClearance`,按 `pierSpacing` 放置轴对齐桥墩盒子
|
||||
5. 车道标线:在沥青表面上偏移的薄带(白边线、黄虚中线、斑马带)
|
||||
|
||||
UV:`u = normalized arc length`,`v = normalized lateral`(逻辑贴图与虚线周期均依赖此约定)。
|
||||
|
||||
### 3. 路口平台
|
||||
|
||||
对度数 ≥ 2 的节点,用入射边端点左右路肩点构成凸包近似平台,扇形三角化;斑马线沿入射方向铺条。
|
||||
|
||||
### 4. 导航叠加 `RoadOverlay`
|
||||
|
||||
- 每条车道:中心线偏移 `±(i+0.5)*laneWidth` 的折线
|
||||
- 路口:同节点合法 `laneLinks` 用三次 Hermite 弧连接
|
||||
- 箭头:沿折线按弧长放置小三角 mesh(group `nav`)
|
||||
|
||||
### 5. 逻辑贴图 `tex.roadMarkings`
|
||||
|
||||
纯函数 `(u,v) → RGBA`:沥青底色 + 边线 + 周期虚线 + 可选斑马/箭头 mask。与几何 UV 对齐,可独立预览或烘焙。
|
||||
|
||||
## 编辑工具流(产品映射)
|
||||
|
||||
与参考图底部工具栏对齐的**语义阶段**(实现可后挂 `procgen_editing`):
|
||||
|
||||
| 阶段 | 文档操作 | 运行时对应 |
|
||||
|------|----------|------------|
|
||||
| 道路编辑 | 增删边控制点 | `RoadEdge.controlPoints` / SplinePathDocument |
|
||||
| 岔路编辑 | 节点连接、匝道 | `RoadNode` + 边拓扑 |
|
||||
| 导航网络 | 生成 laneLinks / overlay | `buildNavigation` |
|
||||
| 调整车道 | lanesForward/Backward、laneWidth | `RoadStyle` |
|
||||
| 烘培模型 | bake → MeshBuild | `bakeRoadNetwork` / `mesh.roadNetwork` |
|
||||
| 样式编辑 | 剖面与材质色 | `RoadStyle` + `tex.roadMarkings` |
|
||||
|
||||
## API 契约
|
||||
|
||||
- 失败路径一律 `Result<T>` / recipe `bool + error`;禁止 `lastError`。
|
||||
- 烘培同步、owner-thread-only;返回值不持有对 `RoadNetwork` 的指针。
|
||||
- Schema:`eve.procgen.roadNetwork` v1(后续 Document 化时沿用)。
|
||||
|
||||
## 验证
|
||||
|
||||
- 单元:剖面宽度、挤出顶点/组、桥墩触发、导航折线非空、配方确定性
|
||||
- 示例:`examples/procedural-road` 烘培立交并画导航折线
|
||||
- 架构:仍落在 `procgen` 宿主内,无向上 `#include`
|
||||
|
||||
## 后续迭代
|
||||
|
||||
1. 路口 curb return / 真正的 mesh stitch
|
||||
2. `RoadNetworkDocument` + gizmo(复用 SplinePathGizmo)
|
||||
3. 地形 conform + 走廊 mask
|
||||
4. 与 `urban` 中心线自动导入
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 668 B |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
@@ -540,7 +540,7 @@ Root-motion 位移会补偿 loop 末尾到开头的跳变;旋转返回单位
|
||||
- AnimRetargetProfile:用 `addBoneMapping()` / `clearBoneMappings()` 管理 Avatar 式骨骼映射;`setNormalizedNameMatching()` / `getNormalizedNameMatching()` 配置自动匹配;`setRootBones()`、`setAutoRootScale()`、`getAutoRootScale()`、`setRootTranslationScale()`、`getRootHorizontalScale()`、`getRootVerticalScale()`、`setUseSkeletonSpaceRotation()`、`getUseSkeletonSpaceRotation()` 配置重定向;`setSkinnedInteractionPreserve()` / `getSkinnedInteractionPreserve()`、`setInteractionContactThreshold()` / `getInteractionContactThreshold()`、`setInteractionCorrectionWeight()` / `getInteractionCorrectionWeight()`、`addInteractionIkChain()` / `clearInteractionIkChains()` 配置 MeshRet 风格蒙皮交互保持;`setNeuralRetargetEnabled()` / `getNeuralRetargetEnabled()`、`setNeuralBackend()` / `getNeuralBackend()`、`setNeuralModelPath()` / `getNeuralModelPath()` 配置可选神经 MeshRet 路径;通过 `getMatchedBoneCount()`、`getUnmatchedBoneCount()`、`getUnmatchedTargetBone()`、`getInteractionCorrectionCount()`、`getNeuralInferenceCount()` 读取最近一次烘焙诊断;配合 `retargetWithProfile()` 使用。
|
||||
- AnimSmrSensorCloud:`fromSkeleton()` 生成骨骼附着传感器,`getSensorCount()` / `getSensorPart()` / `evaluateWorldPositions()` 供诊断或自定义交互查询。
|
||||
- `AnimPose`:`resize()`、`copyFrom()`、`blendFrom()`、`setLocal*()`、`getLocal*()`、`computeWorld()`、`aimBone()`、`solveTwoBoneIK()`、`getWorld*()`、`getWorldMatrixElement()`
|
||||
- `AnimSkin`:`getVertexCount()`、`getBoneCount()`、`getSkeletonBone()`、`getSkinBoneName()`、`getInverseBindElement()`、`updateMatrixPalette()`、`getMatrixPaletteElement()`、`bindGpuMesh()`、`updateGpuMesh()`、`getBindPosition*()`、`getVertexBone()`、`getVertexWeight()`、`updateSkinnedPositions()`、`hasSkinnedPositions()`、`getSkinnedPosition*()`、`getSkinnedPositions()`、`updateSkinnedNormals()`、`hasSkinnedNormals()`、`getSkinnedNormals()`、`applyToMesh()`
|
||||
- `AnimSkin`:`getVertexCount()`、`getBoneCount()`、`getSkeletonBone()`、`getSkinBoneName()`、`getInverseBindElement()`、`updateMatrixPalette()`、`getMatrixPaletteElement()`、`bindGpuMesh()`、`updateGpuMesh()`、`getBindPosition*()`、`getVertexBone()`、`getVertexSkinJoint()`、`getVertexWeight()`、`updateSkinnedPositions()`、`hasSkinnedPositions()`、`getSkinnedPosition*()`、`getSkinnedPositions()`、`updateSkinnedNormals()`、`hasSkinnedNormals()`、`getSkinnedNormals()`、`applyToMesh()`
|
||||
- `AnimPlayer`:`play()`、`crossFade()`、`stop()`、`pause()`、`resume()`、`setSpeed()`、`setTime()`、`setLoop()`、`getPose()`、`setRootMotionBone()`、`getRootMotionBone()`、`getRootMotionX()`、`getRootMotionY()`、`getRootMotionZ()`、`getRootMotionRotationX()`、`getRootMotionRotationY()`、`getRootMotionRotationZ()`、`getRootMotionRotationW()`、`consumeEvent()`、`setUpdateRate()`、`getUpdateRate()`、`update()`;每次更新跨过的事件由 `getEventCount()`、`getEventName()`、`getEventPayload()` 读取,`clearEvents()` 可提前清空。
|
||||
- `AnimGraph`:`addClip()`、`addBlend()`、`addAdditive()`、`addLayer()`、`addOneShot()`、`addBlendSpace1D()`、`addBlendSpace2D()`、`addBlendSpace1DPoint()`、`addBlendSpace2DPoint()`、`setBoneMask()`、`clearBoneMask()`、`setRoot()`、`getRoot()`、`getNodeCount()`、`setWeight()`、`setPosition1D()`、`setPosition2D()`、`setSpeed()`、`trigger()`、`isOneShotActive()`、`setAdditiveReference()`、`getAdditiveReference()`、`getPose()`、`update()`
|
||||
- `AnimBoneMask`:由 `newBoneMask()` 创建;`setAll()`、`setBoneWeight()`、`setBoneWeightByName()`、`setBoneAndChildren()`、`getBoneWeight()`、`getBoneCount()` 定义逐骨权重。
|
||||
|
||||
@@ -26,6 +26,10 @@ src.play();
|
||||
|
||||
从 SoundData 创建 Source,设置 `setLooping(true)` 和音量后 `play()`;暂停菜单用 `pause()`,继续用 `play()`,换场景时 `stop()`。用 `tell()` / `seek()` 实现继续播放。
|
||||
|
||||
`applyUnderwaterAudio(submergeDown,submergeUp,ambience,playDown,playUp,loopAmbience,volume)` 消费水下状态机的
|
||||
一次性转换标志和循环状态。down/up 可为空,ambience 必须有效;函数先验证 `[0,1]` 音量,再设置三个借用
|
||||
Source 的增益、一次性播放下潜/上浮声音,并启动或停止循环环境声。所有 Source 始终由调用者持有。
|
||||
|
||||
### 设置 3D 声源
|
||||
|
||||
每帧同步 Source 的 `setPosition()`、`setVelocity()`,并同步 Audio listener 的位置和朝向。界面音效应 `setRelative(true)`,避免随摄像机衰减。
|
||||
@@ -52,3 +56,12 @@ src.play();
|
||||
|
||||
**源码:** [`src/modules/audio/`](../../../src/modules/audio/)
|
||||
**相关测试:** 在 [`test/`](../../../test/) 中搜索 `audio`。
|
||||
### Pcg 环境音频区域
|
||||
|
||||
`AudioZoneItem` 包含 `volume`、`fadeInTime`、`fadeOutTime`、`duration`。
|
||||
`AudioZoneProfile` 的 `x/y/z`、`radius`、`global`、`minimumBreakTime`、`maximumBreakTime`、
|
||||
`deactivationTime` 描述区域与时序,使用 `addItem` 添加曲目并由 `getItemCount` 查询。
|
||||
`AudioZoneState` 保存 `selectedTrack` 和 `playing`;`evaluateAudioZone` 接收绝对模拟时间、玩家坐标、
|
||||
master volume 和显式 seed,返回 `play`、`stop`、`trackIndex`、`volume` 命令。实现保留 Pcg 的淡入淡出、
|
||||
不连续重复曲目和离开区域后默认 10 秒延迟停播规则,调用者继续拥有实际 `Source`。
|
||||
选择 `trackIndex` 对应的 Source 后,可用 `applyAudioZoneOutput(source,play,stop,volume)` 原子应用命令。
|
||||
@@ -78,6 +78,19 @@ Rig。`setRigPriority` / `setRigEnabled` 可在游戏状态变化时驱动 Direc
|
||||
`addFovImpulse(degrees,duration)` 叠加冲刺/受击常用的 FOV 脉冲。多个 modifier
|
||||
可以同时叠加,不会改变 Rig 的基础参数。
|
||||
|
||||
动作时间线中的 `presentation:camera` 可直接驱动同一套真实 Impulse。先调用
|
||||
`setActionCuesEnabled(true)` 显式把控制器注册为当前动作相机;销毁控制器会自动注销,也可传 `false`
|
||||
提前断开。payload 至少包含 LogicalId 格式的 `cue`,并可设置 `positionAmplitude`、
|
||||
`rotationAmplitude`、`fovAmplitude`、`durationSeconds` 和确定性 `seed`。未注册控制器时运行时返回
|
||||
可观测的 NotFound,不会静默吞掉镜头事件。
|
||||
|
||||
```squirrel
|
||||
ctrl.setActionCuesEnabled(true);
|
||||
// presentation:camera payload 示例:
|
||||
// { cue="combat:heavy-impact", positionAmplitude=0.12,
|
||||
// rotationAmplitude=2.5, fovAmplitude=3.0, durationSeconds=0.3, seed=47 }
|
||||
```
|
||||
|
||||
## Timeline 与 Event
|
||||
|
||||
```squirrel
|
||||
@@ -132,6 +145,7 @@ if (marker != "") print(marker + ": " + ctrl.getTimelineEventData() + "\n");
|
||||
### `CameraController`
|
||||
|
||||
- 绑定:`setCamera(cam)` / `getCamera()`。
|
||||
- Action Cue:`setActionCuesEnabled(enabled)` / `getActionCuesEnabled()`。
|
||||
- 跟随目标:`setTarget(x,y,z)` / `setTargetNode(nodeRef)` / `getTargetX()` /
|
||||
`getTargetY()` / `getTargetZ()`、`setOffset(x,y,z)`、`setLookAhead(x,y,z)`。
|
||||
- 模式:`setMode("follow"|"orbit"|"topdown"|"firstperson"|"cinematic")` / `getMode()`。
|
||||
@@ -147,3 +161,20 @@ if (marker != "") print(marker + ": " + ctrl.getTimelineEventData() + "\n");
|
||||
- `Camera3D` 由脚本创建时默认 active,也可用 `setActive` 控制是否被渲染器采用;
|
||||
控制器只改其 eye/target,不负责渲染。
|
||||
- 示例:[`examples/camera-controllers`](../../../examples/camera-controllers/main.nut)。
|
||||
|
||||
### PcgFreeCamera
|
||||
|
||||
`eve.PcgFreeCamera()` 移植 Pcg 的自由相机。先用 `configure(enabled,lockCursor,holdRightMouse,lookSpeed,moveSpeed,sprintSpeed,scrollIncrease,increaseValue,roll)` 配置,再将设备输入复制到 `eve.PcgFreeCameraInput()` 的 `mouseX`、`mouseY`、`scroll`、`forward`、`right`、`up`、`sprint`、`rightPressed`、`rightReleased`、`leftPressed`、`escapePressed`、`focused` 字段,并调用 `update(camera,input,dt)`。`capture(camera)` / `release()` 显式控制捕获;`setRoll()`、`getCaptured()`、`getSprintSpeed()`、`getYaw()`、`getPitch()`、`getRoll()` 提供状态。cursor 锁定由窗口层根据 `getCaptured()` 和配置执行。
|
||||
### PcgCarCameraSetup
|
||||
|
||||
`eve.PcgCarCameraProfile()` 保留 Pcg `CarControllerSetup` 的完整车辆相机默认值:目标高度、距离范围、墙面间距、水平/垂直速度、俯仰限制、缩放率、旋转/缩放阻尼、碰撞层以及 rear-lock 和鼠标轴开关。用 `eve.PcgCarCameraSetup().configure(profile)` 原子复制配置,再调用 `apply(controller,camera,focus,initialYaw,initialPitch)` 绑定真实 `CameraController`、`Camera3D` 与稳定 `SceneNodeRef`。每帧将鼠标轴、滚轮、dt、目标移动状态与目标 yaw 传给 `update(controller,mouseX,mouseY,scroll,dt,targetIsMoving,targetYaw)`;控制器负责距离钳制、轨道相机和遮挡恢复。
|
||||
|
||||
通用 `CameraController` 同时公开 `getRadius()`、`setRadiusLimits(minimum,maximum)`、`getMinimumRadius()` 和 `getMaximumRadius()`。距离范围更新是结构化 Result;非法范围不会改变现有配置。
|
||||
|
||||
Profile 字段名为 `targetHeight`, `distance`, `offsetFromWall`, `maximumDistance`, `minimumDistance`, `horizontalSpeed`, `verticalSpeed`, `minimumPitch`, `maximumPitch`, `zoomRate`, `rotationDamping`, `zoomDamping`, `collisionLayers`, `lockToRearOfTarget`, `allowMouseHorizontal`, `allowMouseVertical`。
|
||||
|
||||
### Pcg PhotoMode Camera 权威租约
|
||||
|
||||
CameraController.setPhotoModeAuthority(true) 将已通过 setCamera 绑定的控制器注册为照片模式相机字段唯一所有者;传入 false 或销毁控制器时撤销。getPhotoModeAuthority、setCameraRoll、getCameraRoll 和 getPcgCullingDistance 提供状态。照片模式可直接应用 field of view、Pcg culling adjustment、physical aperture、focal length、roll 与 far clip;缺少绑定 Camera3D 时原子失败。
|
||||
|
||||
同一权威也接收 Pcg 的全部 20 个 PostFX 字段。BuiltIn、URP 与 HDRP 的模式、质量和模糊区间会完整保存在 `PcgPhotoModePostFxState`;共享曝光、自动曝光、物理镜头和景深参数会同步投影到绑定的 Camera3D,因此直接进入最终场景合成。URP Gaussian 区间或 HDRP 近远区间生成 EVEngine 的对称 focus range,kernel、最大半径和质量生成最大模糊像素。跨域计划后续失败时,这些保存值与实际 Camera3D 状态一起逆序恢复。
|
||||
@@ -0,0 +1,61 @@
|
||||
# Combat
|
||||
|
||||
Combat 模块提供不依赖具体角色类的伤害、韧性、战斗属性、标准 Ability 原型与移动适配。游戏仍负责角色
|
||||
实体、动画和输入映射;模块不会创建通用 Actor 根类。
|
||||
|
||||
脚本入口 `eve.Combat()` 提供 `getName`,并可通过 `newRuntime()` 创建一个 Squirrel-owned
|
||||
`CombatRuntime`。`registerFighter`、
|
||||
`setMoveIntent`、`navigateTo`、`stop`、`advance`、`applyDamage`、`applyTimelineDamage` 和 `state`
|
||||
全部返回统一 Result 投影;`applyTimelineDamage` 直接消费 Montage runtime event 的 payload JSON,并复用
|
||||
`ActionDamageBinding` 校验,不会在示例脚本中复制伤害字段解析规则。
|
||||
runtime 内部复用下述 C++ 权威实现,不在脚本层复制生命值或移动状态。runtime 随脚本对象 GC 销毁,
|
||||
`ownership()` 返回 `owned`。
|
||||
|
||||
同一个 runtime 还内建并注册 `standardCombatAbilities()` 的 20 个定义。`grantAbility`、
|
||||
`activateAbility`、`advanceAbilities`、`cancelAbility`、`abilityGrant` 和 `matchingAbilities` 直接投影
|
||||
`AbilityRuntime`;冷却与 Action phase 只使用调用者注入的 tick/delta,不读取墙钟。`advanceAbilities`
|
||||
推进所有活跃 execution、递减冷却并同步终态链接,脚本不应另外保存 active/cooldown 状态。
|
||||
`registerTimelineAbility` 接收 `ActionTimelineEditor.snapshotJson()` 的规范 schema,将 timeline 的 actionId、
|
||||
总时长和 physical split 映射为同一个 `ActionDefinition` 的 phase timing;同时显式设置 cooldown、
|
||||
instancing、activation group 和可选 gameplay-event trigger。注册失败不会留下部分 definition。
|
||||
`replaceTimelineAbility` 使用相同参数事务式热替换已有定义:grant、per-owner instance 与剩余冷却保持不变;
|
||||
已激活 execution 继续持有提交时的旧快照,下一次激活才读取新的 timeline 与 actionId。
|
||||
已有 grant 时不能热切换 instancing policy,避免悄悄改变 instance 身份语义。
|
||||
|
||||
## 角色移动与导航
|
||||
|
||||
`CombatLocomotionRuntime` 以 `SubjectRef` 注册角色,并唯一拥有水平位置、速度、朝向、移动意图和导航目标:
|
||||
|
||||
```cpp
|
||||
DirectCombatNavigationProvider navigation;
|
||||
CombatLocomotionRuntime locomotion(navigation);
|
||||
locomotion.registerSubject({fighter, "fighter:player", {0.0, 0.0}, 5.0, 18.0});
|
||||
locomotion.setMoveIntent(fighter, {1.0, 0.0}, 1.0);
|
||||
locomotion.advance({tick, fixedDelta});
|
||||
```
|
||||
|
||||
方向不要求预先归一化,速度比例必须位于 `[0,1]`。玩家或 AI 的直接移动会清除该角色旧导航目标;
|
||||
`navigateTo` 则要求安装 `ICombatNavigationProvider`。内置 `DirectCombatNavigationProvider` 适合无障碍 arena。
|
||||
启用 2D/3D profile 时,可选 `combat_navigation` 卫星提供 `PathfinderCombatNavigationProvider`:
|
||||
|
||||
```cpp
|
||||
map::Pathfinder pathfinder(width, height);
|
||||
auto navigation = combat::navigation::PathfinderCombatNavigationProvider::create(
|
||||
pathfinder, {{worldOriginX, worldOriginZ}, cellSize});
|
||||
CombatLocomotionRuntime locomotion(*navigation.value());
|
||||
```
|
||||
|
||||
它把世界 X/Z 投影到地图格,每步调用 canonical `Pathfinder` 取得下一 waypoint;不会缓存 Path,因此地图阻挡、
|
||||
TileLayer revision 或代价改变会在下一步重新规划。不可达目标返回 `NotFound`,并由 locomotion 的整帧事务语义保证
|
||||
所有角色都不发生部分移动。`Pathfinder` 必须比 provider 活得更久。
|
||||
|
||||
整帧更新具有原子语义:运行时先复制全部状态,再依次取得导航 steering、限制加速度并积分,全部成功后才
|
||||
发布。Provider 失败、返回非有限值或非法比例时,没有任何角色移动。Provider 是借用对象,必须比运行时活得
|
||||
更久,或者在销毁前调用 `clearNavigationProvider`;若清除时仍有活动目标,后续 `advance` 会明确失败,目标不会
|
||||
被悄悄丢弃。到达目标会返回 owning `CombatLocomotionEventKind::Arrived` 事件。
|
||||
|
||||
## 组合边界
|
||||
|
||||
移动状态应由表现层投射到 Scene/Physics,由 Ability Active 阶段决定冲刺、闪避等动作何时写入移动意图。
|
||||
Ability 生命周期仍由 `AbilityRuntime`/`ActionRuntime` 唯一拥有,伤害仍通过 `DamageRuntime` 或
|
||||
`CombatActionDamageSink` 提交;不要在角色 Controller 中复制冷却、动作阶段或生命值。
|
||||
@@ -597,6 +597,12 @@ ws.select("world", "scene", "level-1", "tree-42", "vegetation.tree", false);
|
||||
local created = eve.ActionEditorModule().create("ability.light-attack", timelineAsset);
|
||||
if (!created.ok) throw created.status.summary;
|
||||
local actionEditor = created.value; // ownership == "owned"
|
||||
local canonicalJson = actionEditor.snapshotJson(); // same schema as snapshot(), suitable for runtime transfer
|
||||
|
||||
// A project-owned target adapter may claim selected block types. The Montage
|
||||
// still emits their events, but the preview runtime does not require a native
|
||||
// sink for those types.
|
||||
actionEditor.setRuntimeBlockExternallyHandled("combat:damage", true);
|
||||
|
||||
local ws = editor.newWorkspace("combat", "Combat Action Editor");
|
||||
local composed = actionEditor.configureWorkspace(ws); // Assets/Preview/Inspector/Timeline
|
||||
@@ -607,17 +613,139 @@ actionEditor.pointerDown(mouseX, mouseY, false);
|
||||
actionEditor.pointerMove(mouseX);
|
||||
actionEditor.pointerUp(mouseX);
|
||||
|
||||
// Inspector mutations share the same native transaction history. Disabled
|
||||
// blocks remain authored in schema v4 but do not emit runtime events.
|
||||
actionEditor.setItemEnabled("combat-state:hitbox", false);
|
||||
actionEditor.editItemDetails("combat-state:hitbox", "combat:hitbox-window",
|
||||
"{\"hitbox\":\"weapon.main\"}");
|
||||
|
||||
// Runtime crossings preserve the canonical item identity and owning payload.
|
||||
// Consumers can route the exact edited data into a target-domain adapter.
|
||||
for (local i = 0; i < actionEditor.getRuntimeEventCount(); ++i) {
|
||||
print(actionEditor.getRuntimeEventId(i) + " " +
|
||||
actionEditor.getRuntimeEventPayloadJson(i) + "\n");
|
||||
}
|
||||
|
||||
// Build insertion pickers from the registered semantic notify types, then
|
||||
// insert through the same validation and transaction authority as dragging.
|
||||
local stateCount = actionEditor.getInsertableTypeCount(true);
|
||||
local stateType = actionEditor.getInsertableType(true, 0);
|
||||
local stateLabel = actionEditor.getInsertableTypeLabel(true, 0);
|
||||
actionEditor.addStateAtCursor("combat-track:gameplay", stateType, 0.25,
|
||||
"{\"channel\":\"default\"}");
|
||||
actionEditor.handleTimelineShortcut("Ctrl+C");
|
||||
actionEditor.handleTimelineShortcut("Ctrl+V");
|
||||
|
||||
// Whole-track authoring uses the same canonical timeline transaction history.
|
||||
actionEditor.addTrack("combat-track:camera", "Camera", "camera");
|
||||
actionEditor.renameTrack("combat-track:camera", "Camera Cues");
|
||||
actionEditor.setTrackMuted("combat-track:camera", true);
|
||||
actionEditor.setTrackLocked("combat-track:camera", true);
|
||||
local locked = actionEditor.getTrackLocked(1);
|
||||
actionEditor.copyTrack("combat-track:camera");
|
||||
local pastedTrack = actionEditor.pasteTrack();
|
||||
|
||||
// Animation sections own clip selection, placement, source trim and blending.
|
||||
actionEditor.addAnimationSection("combat-section:strike", "asset://combat.glb#Strike",
|
||||
0.25, 0.65, 0.08);
|
||||
actionEditor.editAnimationSection("combat-section:strike", 0.30, 0.70, 0.10,
|
||||
"asset://combat.glb#StrikeHeavy");
|
||||
actionEditor.editAnimationSectionSource("combat-section:strike", 0.05, 0.40,
|
||||
"ease-in-out");
|
||||
|
||||
// Montage-wide settings commit atomically and update a prepared preview.
|
||||
actionEditor.setMontageSettings(1.25, false, true, 2,
|
||||
0.12, 0.15, 0.0,
|
||||
true, false, true);
|
||||
|
||||
actionEditor.play();
|
||||
actionEditor.update(dt);
|
||||
animationPlayer.setTime(actionEditor.getPreviewTime());
|
||||
|
||||
// Publish an edited owning clip into the prepared Montage without retaining
|
||||
// the editor clip pointer, then skin the real preview mesh from its pose.
|
||||
actionEditor.replaceRuntimeClip("asset://combat.glb#Strike", editedClip);
|
||||
local montagePose = actionEditor.getRuntimePose();
|
||||
|
||||
// Restart through the fixed dual-slot coordinator. The previous pose fades out
|
||||
// while the new execution fades in using the authored default blend-in time.
|
||||
actionEditor.replayRuntimeCrossFade();
|
||||
|
||||
// SceneLoader 等可选模块注册真实预览层后,seek/update 会原子更新表现实例。
|
||||
if (actionEditor.hasPreviewHost())
|
||||
actionEditor.refreshPreview(); // 参数事务提交后刷新当前位置
|
||||
```
|
||||
|
||||
拖动与 Seek 先按 `setSnapSeconds()` 指定的确定性帧网格离散;拖动 Block 或边界时,还会在
|
||||
8 像素阈值内磁吸到 0 点、当前播放头、物理分段、动画 Section 边界和其他轨道 Block 边界。
|
||||
多选拖动会排除选择集自身的边界,避免整组被自己的成员吸住。
|
||||
|
||||
物理 Section 分割不是只读装饰。`getSectionSplitCount/getSectionSplitTime` 读取严格排序的边界,
|
||||
`addSectionSplit` 在播放头或指定时间插入边界,`setSectionSplit` 移动边界,`removeSectionSplit` 删除边界。
|
||||
三种修改都通过 Timeline 的规范替换事务提交,拒绝 0、末尾、重复或越界时间,并立即更新同一编辑器拥有的
|
||||
Montage 运行时副本;Undo/Redo、`jumpRuntimeSection`、`evaluateRuntimeSectionProgress`、
|
||||
`syncRuntimeSection`、`syncRuntimeSectionAndJump`、`clearRuntimeSectionSync` 和磁吸目标因此共享一份边界事实。
|
||||
归一化求值把 `[0,1]` 映射到物理 Section;同步倍率可以只配置,也可以配置后立即跳到该段起点,并可明确清除。
|
||||
运行时按资产的 `animationLayer` 选择协调器层,视口读取该层的双槽归一化合成姿态。
|
||||
`setRuntimeBoneMask` 会同步复制一个与预览骨架匹配的 `AnimBoneMask` 到当前层,调用方随后可以安全销毁或修改
|
||||
原 Mask;`clearRuntimeBoneMask` 恢复全骨骼影响。示例的 Montage Inspector 可从当前选中关节生成 Mask,选择是否
|
||||
包含子关节,并提供紧凑的 Apply/Clear 工具栏。
|
||||
`setRuntimeLayerWeight/getRuntimeLayerWeight` 和 `setRuntimeLayerAdditive/getRuntimeLayerAdditive` 配置当前协调器层,
|
||||
不会写入 ActionTimeline 资产。`getRuntimePoseOverBase(basePose)` 以调用期间借用的基础姿态为底,按层索引依次应用
|
||||
归一化双槽、Layer Weight、Bone Mask 与 bind-pose-relative Additive,返回由编辑器协调器拥有、下次合成前有效的
|
||||
最终姿态;基础姿态骨骼数不匹配或运行时尚未启动时返回 `null`。
|
||||
`getRuntimeState/getRuntimeElapsedSeconds/getRuntimeDurationSeconds/getRuntimeLayer/getRuntimeSlot` 提供只读句柄诊断;
|
||||
示例把它们投影到已有状态行,不增加窗口或面板尺寸。
|
||||
运行中的预览还可用 `setRuntimeRate/getRuntimeRate` 设置和读取严格为正的倍率;倍率只缩放调用者注入的
|
||||
delta,不读取墙钟,也不写回资产的 `basePlayRate`。`getRuntimePhysicalSection`、
|
||||
`getRuntimeSectionProgress` 和 `getRuntimeWeight` 可直接驱动运行诊断 HUD。
|
||||
|
||||
选中的 Audio、VFX 与 Prefab Block 会显示类型化属性:资源 URI、播放参数、停止或生命周期策略,以及
|
||||
attachment、source/target anchor、bone 和位置/旋转/缩放偏移。Audio 还提供分号分隔的随机 URI 池、
|
||||
音高扰动、2D/3D 混合、最小/最大衰减距离,以及退出淡出开关和时长;运行时按 execution/item 的稳定
|
||||
seed 选片段和音高,并用 ActionRuntime 注入的时间推进淡出。
|
||||
VFX State 还提供 `fitBlockToClip` 与 `fitClipToBlock`:前者按有效裁剪区间调整 Block 末端以恢复
|
||||
1.0x 播放,后者保持 Block 不动并调整 `clipEndTime`。两者均拒绝 Instant、非 VFX 与越界候选,且只提交
|
||||
一个可撤销事务。
|
||||
Particles 模块存在时,`fitClipToNaturalDuration` 会通过 `IActionVfxDurationProvider` 读取资源中启用的
|
||||
`emitterLife` 与最大 `particleLifetime`;非循环层取完整消散时长,全循环资源取最长单圈周期。无界资源
|
||||
返回结构化 Unsupported,provider 缺失时按钮隐藏且 API 明确失败,不会把猜测值写入资产。
|
||||
|
||||
能力定义可使用 `encodeAbilityAsset` / `decodeAbilityAsset` 在 owning `Value` 与
|
||||
`eve.action.ability` 资产之间转换。当前 v2 使用纳秒,读取 v1 的毫秒时长后会规范化为 v2;未知顶层字段、
|
||||
未来版本、负时长、非法条件/目标/成本/Timeline 均返回结构化失败,只有完整解码和校验成功的定义才应传给
|
||||
`AbilityRuntime::registerDefinition`。若要写 JSON,可继续调用同一 `Value::toJson/fromJson`,不要复制字段映射。
|
||||
脚本也可用 `setItemPayloadText/Number/Integer/Bool/Vector3` 修改单个字段,或用
|
||||
`setItemPayloadTextList` 把分号分隔文本写成字符串数组,`patchItemPayload` 原子提交一组字段;
|
||||
`getItemPayloadText/Number/Bool/Vector/TextList` 用于把权威 payload 投射回 UI。所有入口先合并到 payload 的拥有型
|
||||
副本,保留未识别扩展字段,再通过 `ActionNotifyRegistry` 校验完整候选值,最后交给
|
||||
`ActionTimelineEditor` 产生一个撤销步骤。校验失败不会增加 revision 或留下部分修改;Advanced JSON 仍是
|
||||
自定义类型和扩展字段的显式逃生口。
|
||||
|
||||
工厂与所有可能失败的编辑操作返回通用 Result 表:`ok`、`value`、`status.code`、
|
||||
`status.summary` 和 `status.diagnostics`。返回的动作编辑器由 Squirrel VM release hook 拥有,
|
||||
仅可在创建它的线程使用;`configureWorkspace` 不保留传入的 Workspace 指针,`snapshot`
|
||||
返回与编辑器生命周期解耦的规范化资产值。完整可运行示例见
|
||||
[`examples/combat-action-editor`](../../../examples/combat-action-editor)。
|
||||
|
||||
`ActionPreviewFrame` 同时携带当前位置的 active blocks。SceneLoader 存在时,Prefab Spawn 预览层
|
||||
会加载真实 `Renderable3D`,但使用与游戏运行时分离的池;准备失败不移动播放头,跳帧离开区间或
|
||||
编辑器销毁会回收预览实例。Audio 模块存在时,同一组合机制使用真实 Source 预览单点和区间音频;
|
||||
连续播放不会每帧重启,跳转按块内时间 seek,参数刷新失败不会破坏当前试听;Audio State 区块还会从
|
||||
真实解码 PCM 绘制有界 min/max 波形,并按 pitch、looping 和区块时长映射。Particles 模块存在时,
|
||||
单点和区间 VFX 使用真实 `ParticleEffect`;Seek/Refresh 从稳定 seed 重模拟到裁剪后的块内时间,
|
||||
连续 Advance 增量模拟且不重建未变实例。裁剪掉全部预览 provider 时
|
||||
`hasPreviewHost()` 返回 false,时间轴编辑和
|
||||
确定性事件采样仍可使用。
|
||||
|
||||
连续参数自动化使用 `presentation:parameter-curve` NotifyState。payload 的 `target` 是 LogicalId,
|
||||
`operation` 可为 `replace`、`add` 或 `multiply`,`keys` 必须包含时间为 0 和 1 的端点,并可为每段选择
|
||||
`step`、`linear` 或 `cubic`。原生 Timeline 会绘制曲线和关键点;选中后 Inspector 可修改时间、数值、切线和
|
||||
插值。脚本可用 `addParameterKey`、`editParameterKey`、`removeParameterKey` 做可撤销编辑,用
|
||||
`getParameterKeyCount/Time/Value/Interpolation` 读取关键帧,并用 `sampleParameterCurve` 取得预览值。
|
||||
内建 `audio:master-volume` target 可直接驱动并在区块结束时恢复主音量;其他领域通过
|
||||
`IActionParameterSink` 注册自己的稳定 target。
|
||||
|
||||
```squirrel
|
||||
local dock = editor.newDock();
|
||||
dock.setRegionSize("left", 200);
|
||||
|
||||
@@ -58,6 +58,12 @@ gfx.renderSprites();
|
||||
`0 < near < far`。`getEyeX/Y/Z` 与 `getTargetX/Y/Z` 读取世界空间眼点和注视点;
|
||||
`getFov()` 返回垂直视野(度),与 `setFov()` 对应。
|
||||
|
||||
Pcg 风格的逐层距离裁剪使用 `Camera3D.setLayerCullDistance(layer, distance)` 和
|
||||
`Camera3D.getLayerCullDistance(layer)`;阴影 caster 使用
|
||||
`Camera3D.setShadowLayerCullDistance(layer, distance)` 和
|
||||
`Camera3D.getShadowLayerCullDistance(layer)`。层编号范围是 0..31,距离 0 表示沿用普通远裁面或阴影
|
||||
级联范围。`Renderable3D.setLayer(layer)` 与 `Renderable3D.getLayer()` 设置和读取对象层。
|
||||
|
||||
### 跨帧绘制 3D 基础图形
|
||||
|
||||
调试可视化、编辑器辅助线和玩法范围提示应使用持久 `Primitive3D`,不需要保留帧内
|
||||
@@ -148,6 +154,21 @@ print(gfx.getMaxAnisotropy());
|
||||
C++ 侧使用 `TextureCreateInfo::withMipmaps()` / `TextureSampler::anisotropic()`,并通过同名 `setTextureSampler` 重载热更新采样状态(字符串版参数与脚本一致)。`filter` 只接受 `nearest`/`linear`,`mipmap` 只接受 `none`/`nearest`/`linear`,传错会抛异常而不是静默回退。Cubemap(IBL)默认生成完整 mip 链,供 `textureLod` 按粗糙度采样。
|
||||
|
||||
几何 LOD:`Renderable3D.setMeshLod(index, mesh, switchDistance)`,`RenderSystem3D` 按相机距离选择网格。
|
||||
`setMeshLodCullDistance(distance)` 设置整条 LOD 链的末级剔除距离,零关闭;
|
||||
`setMeshLodRendererState(index, skinQuality, shadowMode, receiveShadows, motionMode, skinnedMotionVectors,
|
||||
lightProbeUsage, reflectionProbeUsage)` 配置逐级渲染策略,`getMeshLodRendererState(index, field)` 按 0..6
|
||||
读取。设置接口使用结构化 Result,并拒绝 Unity 枚举中不存在的空洞数值。
|
||||
`setMeshLodFadeWidth` 与 `setMeshLodFadePolicy` 配置距离或显式时间驱动的 LOD 渐变;时间模式必须由
|
||||
`advanceMeshLodTransition(distance, dt)` 推进。主渲染和离屏 Canvas 都同时绘制相邻层,alpha 权重互补;
|
||||
`getMeshLodSecondaryLevelAtDistance` 和 `getMeshLodSecondaryWeightAtDistance` 可检查当前混合状态。
|
||||
逐级 `shadowMode=2` 使用无面剔除的双面阴影管线;`shadowMode=1/3` 使用背面剔除管线。普通非 Pcg
|
||||
Renderable3D 保留原有双面阴影默认值。Vulkan 与 WebGPU 对 opaque、alpha-cutout 和 skinned caster
|
||||
使用相同策略。
|
||||
`getMeshLodCullDistance()` 返回当前值。主相机、阴影和反射捕获共享同一选择结果。
|
||||
|
||||
逐级 `lightProbeUsage=2` 使用 Light Probe Proxy Volume。规则探针网格可以在 X/Y/Z 采用不同且任意数量的
|
||||
坐标层;渲染时只选择包含物体的当前网格单元,并在片元世界坐标上对该单元的 2/4/8 个角点执行线性、
|
||||
双线性或三线性二阶球谐插值。缺少角点的非规则探针集合继续使用有界的距离权重回退,不会被误判为规则网格。
|
||||
|
||||
需要把运行对象交给 Editor/MCP 等跨帧工具时,同时读取
|
||||
`Renderable3D.getEntityId()` 与 `getEntityGeneration()`;两者共同构成临时 ECS handle,
|
||||
@@ -294,8 +315,8 @@ WebGPU 使用带 origin 的 `WriteTexture`;两者都不重建 Texture、采样
|
||||
- `newShaderFromSpvFile()`、`replaceShaderFromGlsl()`、`replaceShaderFromWgsl()`、`newTexture()`、`newTextureWithSampler()`、`setRenderableTextureFromImageData()`、`updateTextureFromImageData()`、`setTextureSampler()`、`getMaxAnisotropy()`、`newVolumetric()`、`newAmbientOcclusion()`、`newGlobalIllumination()`、`newAntiAliasing()`、`setMsaaSamples()`、`getMsaaSamples()`、`present()`、`render3D()`、`reset()`、`screenToRay()`、`screenToWorldX()`、`screenToWorldY()`
|
||||
- `sendFloat()`、`sendVec2()`、`sendVec3()`、`sendVec4()`、`setActive()`、`setAmbient()`、`setBackgroundColor()`、`setCamera()`
|
||||
- `setCanvas()`、`setCastOcclusion()`、`setCastShadow()`、`setCloudShadows()`、`setColor()`、`setDirection()`、`setDirectionalLight()`、`setEnabled()`、`setEnvIntensity()`、`setEnvMap()`
|
||||
- `setEye()`、`setFov()`、`setMesh()`、`getMesh()`、`setMeshLod()`、`clearMeshLod()`、`getMeshLodCount()`、`getMeshLodLevelAtDistance()`、`setMetallic()`、`setMorphWeight()`、`setNormalTexture()`、`setHeightTexture()`、`setPosition()`、`setRadius()`
|
||||
- `setReceiveLight()`、`setReceiveShadow()`、`setRotation()`、`setRoughness()`、`setScale()`、`setShader()`、`setHair()`、`getHair()`、`setShadowBias()`、`setShadowStrength()`
|
||||
- `setEye()`、`setFov()`、`setMesh()`、`getMesh()`、`setMeshLod()`、`clearMeshLod()`、`getMeshLodCount()`、`getMeshLodLevelAtDistance()`、`setMetallic()`、`setMorphWeight()`、`setNormalTexture()`、`setPackedNormalMask()`、`setHeightTexture()`、`setPosition()`、`setRadius()`
|
||||
- `setReceiveLight()`、`setCustomLightProbe()`、`setCustomLightProbeCoefficient()`、`clearCustomLightProbe()`、`setReceiveShadow()`、`setRotation()`、`setRoughness()`、`setScale()`、`setShader()`、`setHair()`、`getHair()`、`setShadowBias()`、`setShadowStrength()`
|
||||
- `setTarget()`、`setTexCellBomb()`、`getTexCellBombScale()`、`getTexCellBombStrength()`、`getTexCellBombRotation()`、`setParallax()`、`getParallaxScale()`、`getParallaxMinLayers()`、`getParallaxMaxLayers()`、`setTexture()`、`setTint()`、`setType()`、`setUp()`、`setViewport()`、`setVisible()`、`setVolumetric()`、`setVolumetricIntensity()`、`setYaw()`
|
||||
- `setZoom()`、`worldToScreenX()`、`worldToScreenY()`、`Texture.getMipmapCount()`
|
||||
- 字体:`newFont()`、`setFont()`、`getFont()`、`drawText()`、`print()`、`getAscent()`、`getBaseline()`、`hasGlyph()`
|
||||
@@ -435,3 +456,309 @@ is recorded by the relevant test run, not implied by source availability.
|
||||
GPU 仅持有弱身份,shader 共同拥有图像;释放一个 shader 不影响其他持有者,新文件快照不复用旧内容。
|
||||
新图像在内部按约 64 MiB 暂存批次提交(单个大图像可超过阈值)。同步 Result 失败保留原 shader,
|
||||
脚本无需 begin/end 上传批次。
|
||||
|
||||
### Native vegetation wind reference (C++)
|
||||
|
||||
`VegetationWindState` holds caller-owned direction, strength, published `phase` and private-source-equivalent
|
||||
`updatePhase`. Normally callers use the checked operations rather than editing either phase directly.
|
||||
`advanceVegetationWind(state, forward, strength, dt)` follows Pcg WindManager's
|
||||
smoothed globals, downward direction component and phase advance; dt is explicit.
|
||||
The phase subtracts 100 once when greater than 100, matching the source rather than
|
||||
silently applying a different modulo rule. Invalid or overflowing results preserve state.
|
||||
|
||||
`evaluateVegetationWind(input, state)` implements PW_GeneralWind distance attenuation,
|
||||
main/branch/leaf flex, object scale/orientation compensation and source volume normalization.
|
||||
`VegetationWindInput` includes position, world offset, camera, object transform, width/height,
|
||||
flex/frequency, maximum distance, injected sin(time/4) and sin(time), and enabled/billboard/alphaTest
|
||||
switches. The root's source 0/0 normalization resolves to zero displacement. A singular transform,
|
||||
invalid dimensions/globals or unrepresentable result returns a diagnostic. Double intermediates
|
||||
and float output define tolerance-based behavior, not GPU bit equality.
|
||||
|
||||
These functions provide the CPU reference. The same deformation is now implemented in grass
|
||||
vertex shaders, with typed state/profile and checked Squirrel operations. No wall clock, callbacks, scene references
|
||||
or global state are retained. Caller serializes access to the single mutable state owner.
|
||||
|
||||
`VegetationWindProfile` separates material controls from vertex inputs and defaults to disabled.
|
||||
`packVegetationWind(state, profile, seconds)` returns a validated 14-float snapshot intended for
|
||||
slots 18–31 of the grass shader: direction, strength, phase, signed distance, flex, frequency,
|
||||
sin(seconds/4) and sin(seconds). Zero distance encodes disabled wind; negative distance selects
|
||||
the source billboard branch. Disabling alphaTest zeros leaf flex without changing alpha discard.
|
||||
The calculation is pure and validates disabled profiles too. Grass shaders reserve slots 18–31:
|
||||
`windGlobals` vec4 at 18, `windPhaseDistance` vec2 at 22, `windFlex` vec3 at 24,
|
||||
`windFrequency` vec3 at 27, and `windSineTime` vec2 at 30. Defaults disable motion.
|
||||
`applyGrassWind(shader, state, profile, seconds)` checks the complete wind schema and packet
|
||||
before publishing any uniform bytes. Invalid input/schema leaves the prior shader storage intact.
|
||||
It performs no GPU allocation and retains no references. Call on the Graphics owner thread outside
|
||||
draw/capture callbacks. Re-baking resets shader defaults; reapply the caller-owned snapshot afterward.
|
||||
The terrain-stamping example uses this API with `terrainGrass.getShader()` and an explicit clock.
|
||||
|
||||
`GrassFoliageSettings` selects the static PW/Pcg foliage surface profile. Its script fields are
|
||||
`baseR`, `baseG`, `baseB`, `alphaCutoff`, `normalStrength`, `renderDistance`, `fadeRange`,
|
||||
`hardRenderDistance`, `density`, `snowMinimumHeight`, `snowFadeDistance`, `snowProgress`,
|
||||
`snowR`, `snowG`, and `snowB`.
|
||||
The profile consumes albedo, tangent normal and packed metallic/occlusion/thickness/smoothness maps;
|
||||
the renderer applies distance alpha fade, height snow, native directional/ambient lighting and shadows.
|
||||
|
||||
`TerrainDetailOverwriteSettings` ports Pcg's terrain detail override without merging its three controls:
|
||||
`pcgDetailDistance` starts the shader fade, `pcgFadeoutDistance` is the fade span,
|
||||
`unityDetailDistance` is the outer hard cutoff, and `unityDetailDensity` deterministically thins instances.
|
||||
`detailResolutionPerPatch` is classified with Pcg's exact 2/4/8/16/32 quality mapping and all other values
|
||||
fall back to VeryLow64. Apply it with `GrassField.setTerrainDetailOverwrite(settings)`; the checked Result
|
||||
value is the quality ordinal (VeryLow64=0 through Ultra2=5). The call requires an uploaded foliage profile,
|
||||
copies its settings, publishes all shader values atomically, and retains no references. The getters
|
||||
`getTerrainDetailHardDistance()` and `getTerrainDetailDensity()` expose the applied runtime state.
|
||||
|
||||
The vertex path deforms the camera-facing card in world-relative coordinates, uses the effective
|
||||
instance up scale for the source width/height compensation, and converts back to object space.
|
||||
World/view positions follow deformation while the root varying remains unchanged. Both GLSL and
|
||||
WGSL contain the formula. Vulkan runtime screenshots verified time-dependent movement and exact
|
||||
restoration after disabling wind. Native Dawn/WebGPU runtime also accepted and executed the WGSL;
|
||||
fixed two- and four-second captures differ, while repeated disabled captures are byte-identical.
|
||||
Lighting/material output still differs across the backends, so this establishes deformation behavior
|
||||
rather than cross-backend pixel identity. The terrain-detail adapter also transports deterministic
|
||||
healthy/dry RGB coloring; PBR foliage and the remaining original material effects remain outside this path.
|
||||
|
||||
|
||||
### Squirrel wind controls
|
||||
|
||||
`eve.VegetationWindState()` owns `strength` and `phase` fields, `setDirection(x,y,z)`,
|
||||
`getDirectionX()`, `getDirectionY()` and `getDirectionZ()`. `eve.VegetationWindProfile()` exposes
|
||||
`maximumDistance`, `enabled`, `billboard`, `alphaTest`, `setFlex(x,y,z)` and
|
||||
`setFrequency(x,y,z)`. Configuration is mutable; checked operations validate complete snapshots.
|
||||
|
||||
`eve.advanceVegetationWind(state,x,y,z,strength,dt)` updates the state from a forward direction
|
||||
and explicit nonnegative dt, returning the standard Result table. Invalid values preserve state.
|
||||
`eve.initializeVegetationWind(state,x,y,z,strength)` implements Pcg's distinct
|
||||
`InstantWindApply` path: direction and clamped strength change immediately, and phase becomes
|
||||
`pow(strength*0.5+0.5,3)*0.1`. It preserves `updatePhase`, matching Pcg's private `m_windTime`;
|
||||
the next advance resumes that accumulator and republishes it. It does not reuse smoothing or read a hidden clock.
|
||||
`eve.applyGrassWind(shader,state,profile,seconds)` applies the snapshot without advancing it,
|
||||
returning a Result table and preserving shader storage on invalid values/schema. Squirrel uses its
|
||||
native float clock argument. Null or wrong object types fail VM argument conversion with a type
|
||||
exception before entering the operation. Caller owns state/profile; Graphics owns the grass shader.
|
||||
|
||||
```squirrel
|
||||
local windState = eve.VegetationWindState();
|
||||
local windProfile = eve.VegetationWindProfile();
|
||||
windProfile.enabled = true;
|
||||
assert(eve.initializeVegetationWind(windState,1.0,0.0,0.2,0.5).ok);
|
||||
// dt and seconds are injected by the caller's update loop.
|
||||
assert(eve.advanceVegetationWind(windState,1.0,0.0,0.2,0.5,dt).ok);
|
||||
assert(eve.applyGrassWind(field.getShader(),windState,windProfile,seconds).ok);
|
||||
```
|
||||
|
||||
### Tree prototype vertex wind
|
||||
|
||||
`gfx.newTreeWindShader()` creates a backend-native Mesh3D vertex shader and reuses the engine's
|
||||
default PBR fragment stage. Vulkan consumes embedded SPIR-V; WebGPU consumes matching WGSL.
|
||||
The generic `newMeshShaderFromSpv` and `newMeshShaderFromWgsl` contract treats either empty stage
|
||||
as the corresponding default Mesh3D stage.
|
||||
|
||||
`eve.TreeWindProfile()` owns `bendFactor` and `setDimensions(width,height)`. Dimensions are the
|
||||
unscaled source prototype crown width and height. `eve.applyTreeWind(shader,state,vegetation,tree,seconds)`
|
||||
validates the complete 16-float schema before publishing wind values and dimensions. Pcg bend
|
||||
scales all three flex channels. Invalid input or shader layout preserves all prior parameter bytes.
|
||||
|
||||
The vertex shader keeps the prototype origin fixed and applies PW main-stem, branch and leaf motion
|
||||
in world-relative coordinates. Graphics owns the shader; state/profile objects remain caller-owned.
|
||||
No wall clock or scene link is retained. Call on the Graphics owner thread outside draw callbacks.
|
||||
|
||||
### Pcg 水面系统控制器
|
||||
|
||||
`eve.WaterSystemSettings()` 配置 `refreshRate`、`infiniteMode`、`autoRefresh`、
|
||||
`ignoreSceneConditions`,并通过 `setAutoUpdateMode(0|1)` 选择 Interval 或 SceneConditions。
|
||||
`eve.WaterSceneConditions()` 提供 `sunIntensity`、`hour`、`minute`、`sunAvailable`、
|
||||
`timeAvailable`,以及 `setSunColor(r,g,b)`、`setSunDirection(x,y,z)`。调用者提交快照,
|
||||
因此控制器不会搜索全局场景,也不会持有相机、太阳或玩家指针。
|
||||
|
||||
`eve.WaterSystemState()` 暴露权威 `seaLevel`、`positionX`、`positionY`、`positionZ`、
|
||||
`refreshRemaining`、`sceneCheckFrames`、`refreshRevision` 和 `initialized` 状态。
|
||||
先调用 `initializeWaterSystem(state,settings,scene,initialFrames)`;随后每帧调用
|
||||
`advanceWaterSystem(state,settings,scene,playerX,playerZ,dt,nextFrames)`。返回的 Result 值为
|
||||
本帧是否应重新生成反射。无限模式把 X/Z 跟随输入位置并始终把 Y 锁定为 seaLevel。
|
||||
Interval 模式保留 Pcg 的严格小于零触发和 refreshRate 重置;SceneConditions 比较太阳颜色、
|
||||
强度、方向与可选时间,并使用调用者注入的 nextFrames 取代隐藏随机数。
|
||||
`updateWaterSeaLevel(state,level,regenerate)` 原子更新高度,并在要求重建时递增 refreshRevision。
|
||||
所有失败都保留原状态;dt、刷新间隔、场景值和延迟会完整验证。
|
||||
### Pcg WindManager 音量控制
|
||||
|
||||
`eve.VegetationWindAudioState()` 与
|
||||
`eve.advanceVegetationWindAudio(state,windStrength,transitionTime,dt,enabled,clipAvailable)` 移植
|
||||
WindManager 的风声控制器。状态拥有 currentWindSpeed、anchorVolume、volume、processing 和 playing;
|
||||
函数只计算确定性的音量状态,不跨模块持有 Audio Source。脚本应创建循环、listener-relative Source,
|
||||
每帧把 state.volume 写入 Source。实现保留源码行为:currentWindSpeed 不更新,非零风速每帧重启处理;
|
||||
上升在目标下方 0.05 内吸附,下降比较不对称而通常渐近目标。dt 与 transitionTime 均显式注入。
|
||||
|
||||
### Pcg 程序化水面网格
|
||||
|
||||
`eve.WaterMeshSettings()` 提供 `sizeX`、`sizeY`、`sizeZ`、`densityX`、`densityY`、`height`,
|
||||
并通过 `setType(0|1)` 选择 Plane 或 Circle。`eve.calculateWaterMeshTriangles(settings)` 在分配前返回
|
||||
Pcg 算法的三角形数量;`water.createProceduralMesh(settings)` 验证并上传网格,返回顶点数量。
|
||||
Plane 按 `round(density/40*size)+1` 建立规则格点。Circle 使用同心六边环,环数取
|
||||
`round(densityX/40*sizeX/2)`,并保留 Pcg 的环缝合索引、UV 缩放与非对称 X/Z 密度行为。
|
||||
无效、过小或超过单轴 4096 点的输入返回诊断且保留 Water 当前网格。Custom 对应调用者直接提供
|
||||
EVEngine Mesh,不复制 Unity MeshFilter 的资源引用模式。
|
||||
|
||||
### PWS 水波方向
|
||||
|
||||
`water.setWaveDirectionAngle(degrees)` 返回标准 Result,并设置 PWS_WaterSystem 的 `directionAngle`,并将任意有限角度归一化到
|
||||
[0,360);`water.getWaveDirectionAngle()` 返回当前角度。Vulkan GLSL 与 WebGPU WGSL 都按该角度旋转
|
||||
顶点位移和片元细浪坐标。实现把角度编码在 128 字节水参数块的 ripple-count 小数部分;整数涟漪数量保持
|
||||
不变,避免超过 Vulkan 保证支持的最小 push-constant 容量。
|
||||
|
||||
### Pcg 水深色带
|
||||
|
||||
`eve.WaterDepthGradient()` 分别拥有颜色键和透明度键。`addColorStop(time,r,g,b)` 与
|
||||
`addAlphaStop(time,a)` 返回 Result;`clear()` 清空两组键。`water.setDepthGradient(gradient,resolution)`
|
||||
生成 `resolution × resolution` 的 RGBA8 Clamp 色带,并严格按 Pcg 使用 `x/resolution` 采样,
|
||||
返回像素数量。可通过 `getDepthGradientTexture()` 将同一纹理设置给使用 Water shader 的
|
||||
Renderable3D normal slot;直接调用 Water.draw 时会自动绑定。`clearDepthGradient()` 恢复深浅双色模式。
|
||||
无键、重复时间或 2..4096 外的分辨率失败并保留当前纹理。
|
||||
|
||||
### Pcg 平面水反射规划
|
||||
|
||||
`eve.WaterPlanarReflectionSettings()` 移植 `PcgPlanarReflections.PlanarReflectionSettings`:反射开关、
|
||||
天空跳过、Full/Half/Third/Quarter 分辨率倍率、clip-plane 偏移、反射层、阴影、统一或逐层渲染距离和
|
||||
基础纹理分辨率。`setResolutionMultiplier(0..3)` 选择倍率,`setLayerDistance(layer,distance)` 设置 32 层距离。
|
||||
`eve.WaterPlanarReflectionInput()` 通过 `setCameraPosition`、`setCameraForward` 及 `waterPlaneY`、`renderScale`、
|
||||
`orthographic`、`reflectionOrPreviewCamera` 提供不可变帧快照。
|
||||
|
||||
调用 `buildWaterPlanarReflectionPlan(output,settings,input)` 后,output 包含镜像相机位置/方向、世界裁剪平面、
|
||||
反射矩阵、排除水层 4 的 culling mask、32 层距离、捕获分辨率和 `shouldRender`。Third 精确保留 Pcg 的
|
||||
0.33 倍率及整数截断。规划器纯计算且不持有相机、纹理或场景节点;调用者用结果配置现有离屏捕获,再将纹理
|
||||
传给 `Water.drawWithPlanarReflection`。非法枚举、非有限值、负距离或无效分辨率返回 Result,保持 output 不变。
|
||||
|
||||
设置字段为 `disableSkyboxReflections`、`clipPlaneOffset`、`reflectLayers`、`shadows`、
|
||||
`enableRenderDistance`、`enablePerLayerDistances`、`customRenderDistance` 和 `textureResolution`。
|
||||
结果字段为 `textureWidth`、`textureHeight`、`cullingMask`、`renderShadows`;读取镜像数据使用
|
||||
`getCameraX()`、`getCameraY()`、`getCameraZ()`、`getForwardY()`、`getLayerDistance(layer)` 与
|
||||
`getReflectionMatrix(column,row)`。
|
||||
|
||||
### Pcg 水下效果状态机
|
||||
|
||||
`UnderwaterColorGradient.addStop(time,r,g,b)` 建立深度或时段颜色渐变,`clear()` 清空键。
|
||||
`WaterUnderwaterSettings` 提供 `enabled`、`supportFog`、`supportPostFx`、`enableTransitionFx`、
|
||||
`useCaustics`、`hdrp`、`overrideFogColor`、`fogDepth`、`fogDistance`、`hdrpFogDistance`、
|
||||
`nearFogDistance`、`fogDensity`、`playbackVolume`、`causticSize`、`framesPerSecond`、
|
||||
`causticTextureCount`、`overrideFogCurve`、`anisotropyShallow`、`anisotropyDeep`,并用
|
||||
`setFogColorMultiplier`、`setOverrideFogMultiplier` 写入颜色乘数。
|
||||
|
||||
`WaterUnderwaterInput` 的 `cameraY`、`seaLevel`、`timeOfDay`、`causticTicks`、`hasMainLight` 和
|
||||
`setMainLightColor` 是不可变帧输入。调用
|
||||
`advanceWaterUnderwaterEffects(state,output,settings,input,depthGradient,timeGradient,postExposureCurve,postColorGradient)` 后,
|
||||
`WaterUnderwaterState` 保存 `initialized`、`isUnderwater`、`causticFrame`;output 发布
|
||||
`entered`、`exited`、`playSubmergeDown`、`playSubmergeUp`、`loopAudio`、`particles`、`horizon`、
|
||||
`surfaceVfx`、`postFx`、`transitionFx`、`caustics`、`causticSize`、`depth01`、`fogStart`、
|
||||
`fogEnd`、`hdrpBaseHeight`、`hdrpMeanFreePath`、`hdrpProbeDimmer`、`hdrpAnisotropy`、
|
||||
`hdrpDepthExtent`,颜色由 `getFogR()`、`getFogG()`、`getFogB()` 读取。
|
||||
|
||||
相机位于海面或以下视为水下。普通路径按深度渐变、主光颜色和非负分量偏移生成雾;override 路径按
|
||||
timeOfDay 渐变和乘数生成雾。HDRP 输出精确保留 Pcg 的深度插值常量。音频、粒子、Volumetric、灯光
|
||||
cookie 与后处理仍由各 provider 消费该快照;状态机不持有跨模块对象。所有输入先完整验证,失败时 state
|
||||
与 output 均保持不变。
|
||||
|
||||
`WaterSurfaceFogSnapshot` 保存离水后恢复的 `density`、`start`、`end`、`height`、`heightFalloff`,
|
||||
颜色通过 `setColor(r,g,b)` 设置。`applyWaterUnderwaterFog(volumetric,output,surface)` 在完整验证后,把水下
|
||||
`fogColor`、`fogDensity`、`fogStart`、`fogEnd`、`fogHeight` 写入借用的 `Volumetric`;离水时恢复 surface。
|
||||
EVEngine 的距离雾要求非负起点,因此 Pcg 默认 `nearFogDistance=-4` 在 provider 边界钳制到 0。
|
||||
`Volumetric.projectDirectionalCookie(graphics,depth,cookie,worldSize,intensity)` 从硬件深度重建世界位置,
|
||||
沿主光方向建立投影平面并重复采样 cookie;参数只在本次渲染调用中借用,不跨帧保存纹理指针。
|
||||
|
||||
`UnderwaterScalarCurve.addKey(time,value)` 与 `clear()` 定义时段曝光曲线。设置中的
|
||||
`timeDrivenPostFx`、`constantPostExposure`、`setConstantPostColor(r,g,b)` 选择 Pcg 的天气时段模式或
|
||||
固定模式。输出的 `postExposure` 和 `getPostR()`、`getPostG()`、`getPostB()` 是本帧后处理值;
|
||||
`WaterSurfacePostFxSnapshot` 用 `exposureEv` 和 `setColor(r,g,b)` 保存表面状态。
|
||||
`applyWaterUnderwaterPostFx(graphics,camera,output,surface)` 将水下曝光写入借用的 Camera3D,并把 RGB
|
||||
颜色过滤写入 Graphics 的最终 HDR 曝光阶段;离水时原子恢复 snapshot。Vulkan SPIR-V 与 WebGPU WGSL
|
||||
使用相同的线性颜色乘法。
|
||||
Pcg 内置 transition prefab 的 Y 尺寸 0.05 和 `blendDistance=0.025` 分别对应设置中的
|
||||
`transitionHalfHeight=0.025` 与 `transitionBlendDistance=0.025`。水下距海面不超过半高时
|
||||
`transitionWeight=1`,随后在 0.025 米内衰减到 0;`transitionVignette=0.25` 和
|
||||
`transitionLensDistortion=0.252` 来自原始 Post Processing asset。输出发布同名 vignette/lens 值,
|
||||
`WaterSurfacePostFxSnapshot.vignette` 与 `lensDistortion` 保存表面状态;适配器将它们接入 Vulkan/WebGPU
|
||||
最终 HDR 合成的径向暗角和 UV 畸变。该 profile 启用的 Lift/Gamma/Gain trackball 先按 Unity Post Processing v2 的
|
||||
`ColorToLift`、`ColorToInverseGamma`、`ColorToGain` 转为 HDR 系数,再按 `transitionWeight` 从
|
||||
中性值混合;`setTransitionLift`、`setTransitionInverseGamma`、`setTransitionGain` 可覆盖资源值,
|
||||
`WaterSurfacePostFxSnapshot.setLiftGammaGain(...)` 保存离水后的恢复状态。profile 的黑色 Color Filter 同样按
|
||||
过渡权重从白色混合,并与既有水下时段颜色相乘;`setTransitionColorFilter(r,g,b)` 可覆盖该资源值。
|
||||
脚本可用 `getTransitionLiftR()`、`getTransitionInverseGammaR()`、`getTransitionGainR()` 和
|
||||
`getTransitionColorFilterR()` 读取代表性的红通道结果以诊断当前混合状态。
|
||||
`transitionVignetteSmoothness=0.8`、`transitionLensScale=1.02`(surface snapshot 中对应
|
||||
`vignetteSmoothness`、`lensScale`)保留 profile 的其余启用值;混合时分别从
|
||||
Unity 的中性值 0.2 和 1.0 过渡。最终 pass 使用 Post Processing v2 的 classic vignette 幂函数以及正镜头
|
||||
畸变的 theta/sigma/tangent 采样公式,而非简单二次 UV 偏移。
|
||||
`WaterSurfaceMaterialSnapshot` 用 `setColor(r,g,b)` 和 `alpha` 保存水面 tint。
|
||||
`applyWaterUnderwaterMaterial(renderable,output,surface)` 对应 Pcg `UpdateUnderwaterMaterial`:水下把
|
||||
`getUnderwaterMaterialR()`、`getUnderwaterMaterialG()`、`getUnderwaterMaterialB()` 发布的颜色写入专用水面
|
||||
Renderable3D,离水时恢复 snapshot。有主光时颜色来自 `mainLightColor`,没有主光时使用 Pcg 的
|
||||
`#CFCFCF` 回退值;适配器同步借用对象且在验证完成前不修改 tint。
|
||||
`applyWaterUnderwaterHorizon(horizon,output)` 同步借用 Renderable3D,并严格按状态机的 `horizon` 输出控制
|
||||
可见性;HDRP 路径和离水状态都会隐藏该网格。`getVisible()` 可读取最终可见状态。
|
||||
|
||||
`WaterUnderwaterDisableTriggerState` 与 `WaterUnderwaterTriggerEvent` 对应 Pcg
|
||||
`DisableUnderwaterFXTrigger`。事件的 `sensorTag`、`visitorTag`、`entered`、`exited` 从 `World3D` 的
|
||||
begin/end trigger 缓冲复制 Sensor/Visitor Shape Tag,
|
||||
再调用 `advanceWaterUnderwaterDisableTrigger(state,event,expectedSensorTag,expectedVisitorTag)`;匹配的 enter
|
||||
把 `effectsEnabled` 设为 false,匹配的 exit 恢复为 true,其他标签不改变状态。每个事件必须恰有一个方向,
|
||||
非法标签或方向在发布前失败。将结果写入 `WaterUnderwaterSettings.enabled` 后再推进水下状态机。
|
||||
|
||||
Pcg 自动景深跟焦计算使用 `DepthOfFieldFocusSettings/Input/State/Output` 和
|
||||
`evaluateDepthOfFieldFocus(state, output, settings, input)`。应用层负责用 World3D 或其他权威场景查询提供
|
||||
中心射线命中距离或目标距离;计算器复现 FollowScreen、FollowTarget、FixedOffset、玩家遮挡回退到最大距离
|
||||
以及 `deltaSeconds * response` 平滑,并输出 focusDistance、aperture 和 focalLength。状态由调用者持有。
|
||||
字段 `tracking`、`interactWithPlayer`、`focusOffset` 控制模式与偏移;输入字段 `hasRayHit`、
|
||||
`hitPlayer`、`rayHitDistance`、`hasTarget`、`targetDistance` 描述查询结果。状态字段
|
||||
`maximumExceeded` 记录玩家遮挡或最大距离回退,输出字段 `active` 表示效果是否启用。
|
||||
|
||||
`gfx.newDepthOfField()` 创建跨后端景深 compositor。`DepthOfField.apply(source, linearDepth,
|
||||
focusDistance, focusRange, maximumBlurPixels, nearPlane, farPlane)` 返回借用的内部 HDR 结果纹理;
|
||||
`applyTo` 写入调用者提供的 Canvas。`linearDepth` 使用 GBuffer `getDepthTexture()` 的归一化线性深度。
|
||||
焦距附近保持清晰,离焦程度按世界距离差增长,并以 `maximumBlurPixels` 限制采样半径。
|
||||
活动 Camera3D 可调用 `setDepthOfField(focusDistance, focusRange, maximumBlurPixels)`,把镜头状态接入
|
||||
最终场景合成;`clearDepthOfField()` 关闭效果。启用时应同时在 RenderControl 开启 G-buffer。
|
||||
|
||||
### Pcg 反射探针距离裁剪
|
||||
|
||||
`ReflectionProbeRegistry.setDistanceCullingEnabled(true)` 启用 Pcg 风格的硬距离资格;
|
||||
`setMaxRenderDistance(distance)` 设置正且有限的相机到探针中心距离。每次 `updateCamera(camera)` 更新
|
||||
所有探针的资格,`getLastDistanceCulledCount()` 返回被拒绝数量。距离外探针不会消耗 `tick()` 的捕获和
|
||||
过滤预算;重新进入范围会排队一次完整 recapture。距离判断使用严格的 `distance >= maximum` 淘汰,
|
||||
对应 Pcg 的 `distance < maximum` 启用规则。注册表仍负责八槽稳定选择、优先级和滞回。
|
||||
|
||||
`getDistanceCullingEnabled()` 与 `getMaxRenderDistance()` 返回当前配置,便于运行时面板和存档系统读取。
|
||||
|
||||
Camera3D 的物理镜头与裁剪读取接口为 setPhysicalLens、getAperture、getFocalLength、getNearClip 和 getFarClip。物理参数保存在相机权威组件中,供照片模式和后处理共同读取。
|
||||
|
||||
### Pcg PhotoMode Water 权威配置
|
||||
|
||||
PcgWaterPhotoModeAuthority 通过 setAuthority 管理唯一 Water 字段租约。它接收反射开关、额外距离、分辨率、LOD bias、Underwater fog color、density 与 distance 七个字段。applyToPlanarSettings 将值写入 WaterPlanarReflectionSettings;其中 lodBias 同时缩放实际 capture layer culling distance。applyToUnderwaterSettings 将颜色和雾参数写入 WaterUnderwaterSettings,水下求值会直接使用照片模式颜色。
|
||||
|
||||
### Pcg PhotoMode Grass 权威配置
|
||||
|
||||
`GrassField.setPhotoModeAuthority(true)` 将当前 GPU 草地场注册为照片模式 Grass 域唯一所有者;关闭或销毁字段时撤销。`m_globalGrassDensity` 控制 shader 的确定性实例密度丢弃,`m_globalGrassDistance` 与 `m_cameraCellDistance` 缩放硬裁剪、淡出和渲染距离,`m_cameraCellSubdivision` 以二次幂调整 cell 覆盖范围。`getPhotoModeDensity`、`getPhotoModeDistance`、`getPhotoModeCellDistance` 和 `getPhotoModeCellSubdivision` 返回当前倍率。重新上传 foliage 或调用 `setTerrainDetailOverwrite` 后会再次应用这些倍率。
|
||||
|
||||
### Pcg PhotoMode Graphics 权威配置
|
||||
|
||||
`PcgGraphicsPhotoModeAuthority` 绑定 Graphics、RenderControl 与 Camera3D 后,以显式租约接收 LOD bias、anti-aliasing、shadow distance、shadow resolution 和 shadow cascades。LOD bias 直接缩放所有 Renderable3D 网格 LOD 的选择距离;AA 枚举切换真实 FXAA、SMAA、TAA 或关闭合成;阴影距离写入 Camera3D 每层 caster 裁剪。完整分辨率和级联设置保存在同一状态中,供阴影资源配置使用。
|
||||
|
||||
状态读取方法为 getAuthority、getReflectionEnabled、getReflectionDistance、getReflectionResolution、getReflectionLodBias、getFogDensity、getFogDistance、getFogRed、getFogGreen 和 getFogBlue。WaterPlanarReflectionSettings 也公开 lodBias。
|
||||
|
||||
### Pcg PhotoMode 全局阴影倍率
|
||||
|
||||
`PcgGraphicsPhotoModeAuthority` 同时接受 Lighting 域的 `m_globalShadowDistanceMultiplier`。实际 32 层 shadow caster 裁剪距离为 `m_shadowDistance * multiplier`,基础距离与倍率分别保存,任一字段变化都会重新投影。
|
||||
### GPU skin influence limit
|
||||
|
||||
The native `Graphics::setMesh3DSkinInfluenceLimit` draw state selects the strongest 1, 2, or 4
|
||||
vertex influences for subsequent skinned mesh draws. Retained weights are
|
||||
renormalized in the forward, G-buffer, and shadow vertex paths. `Renderable3D`
|
||||
applies Pcg LOD `SkinQuality` per selected level; `Auto` resolves to the engine
|
||||
default of four influences.
|
||||
|
||||
### Pcg ReflectionMasker
|
||||
|
||||
`eve.WaterReflectionMasker()` 根据玩家在当前地形中的归一化 X/Z 位置控制平面反射。
|
||||
`configure(channel,min,max,enabled)` 的 channel 为 0 R、1 G、2 B、3 A、4 RGBA,阈值为闭区间;
|
||||
RGBA 模式任一通道命中即启用。`setMask(imageData)` 会复制像素,不跨帧保留图片指针,`clearMask()`
|
||||
清除遮罩。`evaluate(playerX,playerZ,terrainX,terrainZ,width,depth,reflectionSettings)` 返回 0 Unchanged、
|
||||
1 Enabled 或 2 Disabled,并在状态变化时同步 `WaterPlanarReflectionSettings.enabled`。
|
||||
`getEnabled()`、`getHeightFeaturesEnabled()`、`getSampleX()` 和 `getSampleY()` 返回最近状态与采样坐标。
|
||||
@@ -0,0 +1,347 @@
|
||||
# 六边形立体地图(Hex Map)
|
||||
|
||||
**脚本入口:** `hexmap`(`eve.HexMap`,模块槽位 `hexmap`)
|
||||
|
||||
`hexmap` 是引擎的**可编辑 3D 六边形地图**层:持有尖顶(pointy-top)六边形格网、
|
||||
按 5×5 分块、按面(terrain / water / river / road)生成 CPU 网格并上传 GPU,同时
|
||||
提供世界坐标拾取、邻居查询与笔刷编辑。参考实现是 Catlike Coding 的
|
||||
[Hex Map](https://catlikecoding.com/unity/hex-map/) 工程;坐标、高程台地、悬崖、
|
||||
水面与河道几何均与之一致。
|
||||
|
||||
## 布局与常量
|
||||
|
||||
| 项 | 值 | 说明 |
|
||||
|----|----|------|
|
||||
| 朝向 | 尖顶 | XZ 平面,+X 向东,+Z 向北,Y 为上 |
|
||||
| `outerRadius` | 10 | 六边形外接圆半径 |
|
||||
| `innerRadius` | 8.66025404 | `outerRadius * √3/2` |
|
||||
| `elevationStep` | 3 | 每级高程的世界高度 |
|
||||
| `solidFactor` / `waterFactor` | 0.8 / 0.6 | 实心区与水面内缩因子 |
|
||||
| `terracesPerSlope` | 2(`terraceSteps` = 5) | 斜坡台地级数 |
|
||||
| `kChunkSizeX/Z` | 5 / 5 | 分块尺寸;地图尺寸必须是它的整数倍 |
|
||||
| 地面扰动 | XZ ±4,Y ±1.5 | `HexNoise`(种子决定,无需贴图资源) |
|
||||
|
||||
偏移坐标到轴坐标:`axial = offset - (z/2, 0)`(`z/2` 向下取整)。世界坐标:
|
||||
`x = (axialX + 0.5·axialZ) · innerDiameter`,`z = axialZ · outerRadius · 1.5`。
|
||||
`HexCoordinates::fromWorldPosition` 是其精确逆变换(含立方体取整修正)。
|
||||
|
||||
## C++ API 快查
|
||||
|
||||
### `HexMetrics`([HexMetrics.h](../../../src/modules/hexmap/HexMetrics.h))
|
||||
|
||||
常量与角点/边采样助手:`firstCorner` / `firstSolidCorner` / `firstWaterCorner` /
|
||||
`bridge` / `waterBridge` / `solidEdgeMiddle` / `terraceLerp` / `wallLerp`,
|
||||
以及 `elevationY` / `streamBedY` / `waterSurfaceY`。`EdgeVertices` 是边上 5 个
|
||||
采样点(v1…v5)。
|
||||
|
||||
### `HexCoordinates`([HexCoordinates.h](../../../src/modules/hexmap/HexCoordinates.h))
|
||||
|
||||
轴坐标 `(x, z)`,`y = -x - z`。`step(direction, distance)`、`distanceTo`、
|
||||
`fromOffset` / `offsetX` / `offsetZ`、`chunkColumn` / `chunkRow`、
|
||||
`toWorldPosition` / `fromWorldPosition`。
|
||||
|
||||
### `HexMap`([HexMap.h](../../../src/modules/hexmap/HexMap.h))
|
||||
|
||||
值类型格网,`reset(cellCountX, cellCountZ, seed)` 建立地图(尺寸必须是 5 的倍数)。
|
||||
|
||||
- 拓扑:`contains` / `indexOf` / `coordinatesAt` / `tryGetNeighbor` / `edgeTypeTo`
|
||||
- 分块:`chunkIndexOf` / `chunkColumnOf` / `chunkRowOf` / `chunkCenter` /
|
||||
`takeDirtyChunk` / `markAllChunksDirty` / `dirtyChunkCount`
|
||||
- 拾取:`pickCell(rayOrigin, rayDirection)` —— 无碰撞体,改为在候选格高程平面上
|
||||
迭代求交并二分收敛
|
||||
- 查询:`elevation` / `waterLevel` / `terrainType` / `urbanLevel` / `farmLevel` /
|
||||
`plantLevel` / `specialIndex` / `isUnderwater` / `hasRiver` / `hasRoad` / `isWalled`
|
||||
- 编辑:`setElevation` / `setWaterLevel` / `setTerrainType` / `setUrbanLevel` /
|
||||
`setFarmLevel` / `setPlantLevel` / `setSpecialIndex` / `setWalled` /
|
||||
`setOutgoingRiver` / `removeRiver` / `addRoad` / `removeRoads`
|
||||
- 笔刷:`editElevation` / `editWaterLevel` / `editTerrainType` / `editFeatureLevel`
|
||||
(六边形圆盘,`collectBrush` 可单独枚举)
|
||||
|
||||
跨格约束与参考工程一致:河流只能顺坡或从湖面流出;道路不能跨两级以上高程、
|
||||
不能穿过河流中段、城市/特殊地物会移除道路;特殊地物在有河时被忽略。
|
||||
|
||||
### 网格生成([HexMapMesh.h](../../../src/modules/hexmap/HexMapMesh.h))
|
||||
|
||||
`buildChunkSurfaceMesh(map, chunkIndex, surface, out)`,`surface` 取
|
||||
`HexSurface::{Terrain, Water, River, Road}`。生成物是 `HexMeshData`
|
||||
(position / normal / uv / index,三角形不共享顶点,`finalize()` 写平面法线)。
|
||||
|
||||
分块所有权与参考工程一致:地面扇形按本块 25 格生成,**只有 NE / E / SE**
|
||||
方向的混合带、台地与悬崖归属本块,其余由邻块生成,因此块与块之间不会重复或漏缝。
|
||||
|
||||
顶点只有两个纹理坐标,地形三层信息按
|
||||
`u = layerA + layerB·8 + layerC·64`、`v = weightB + weightC·16` 打包;这是当前
|
||||
顶点布局的**兼容桥**,不是地形数据格式。
|
||||
|
||||
`HexSurface::Fog`(值 4)是唯一不能由 `buildChunkSurfaceMesh` 生成的流,因为它
|
||||
除了格子还需要可见性计数器,由 `buildFogMesh(map, visibility, chunkIndex, out)`
|
||||
生成:每个"当前不可见"的格子输出一根六棱柱(顶面 + 侧裙),裙脚垂到最低可编辑
|
||||
高程之下,因此迷雾边界上的悬崖立面也会被覆盖;第一纹理坐标 `u` 承载明暗
|
||||
(`0` = 曾经探索过但此刻无人看见,`1` = 从未探索)。迷雾流在可见性跨越 0/1 时
|
||||
自动把所在块标脏。
|
||||
|
||||
另外两条流由 [HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h) 生成,也不走
|
||||
`buildChunkSurfaceMesh`:`HexSurface::Wall`(值 5,`buildWallMesh`)输出城墙、墙塔
|
||||
与桥梁,`HexSurface::Feature`(值 6,`buildFeatureMesh`)输出 urban / farm / plant /
|
||||
special 地物。两者共用一条片元着色器,因为第一纹理坐标已经标明了部件种类:
|
||||
|
||||
| `u` | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 部件 | 城墙 | 墙塔 | 桥梁 | urban | farm | plant | special |
|
||||
|
||||
城墙的归属规则与地形一致:只有当 `d ∈ {NE, E, SE}` 或该方向已经在网格之外时,
|
||||
才由本块生成该边的城墙——这样每条内部边恰好被一侧生成,而地图边界仍然闭合。
|
||||
道路横穿城墙边界时会开出城门(只生成两侧外段 + 两个端帽);高程差产生楔形挡墙。
|
||||
|
||||
### 地物与城墙([HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h))
|
||||
|
||||
参考工程实例化 prefab 模型;本引擎不携带资源,因此 `HexFeatures.cpp` 用程序化几何
|
||||
替代(墙=两侧面 + 顶盖,塔/桥/建筑=盒体,plant=压扁八面体,special=锥形柱)。
|
||||
`HexHashGrid` 是参考工程 `hashGrid` 表的确定性替代:由 `(gridX, gridZ, seed,
|
||||
channel)` 的整数哈希直接导出五个分量,因此不需要分配表、也没有全局状态,同一地图
|
||||
种子永远得到同一套地物布局。`featureThreshold(level, index)` 返回参考工程的
|
||||
三级阈值表 `{0,0,0.4} / {0,0.4,0.6} / {0.4,0.6,0.8}`,越界参数被钳制。
|
||||
|
||||
拾取规则与参考工程一致:urban / farm / plant 三个集合各自用格子哈希判定是否
|
||||
命中,命中的里面哈希最小者胜出;`specialIndex` 非零时该格不生成普通地物。
|
||||
|
||||
### 地图生成([HexMapGenerator.h](../../../src/modules/hexmap/HexMapGenerator.h))
|
||||
|
||||
`generateHexMap(map, settings)` 把参考工程 `MapGenerator` 的流水线移植为纯 CPU 步骤:
|
||||
|
||||
1. 全部格子初始化到 `elevationMinimum`;
|
||||
2. `regionCount` 个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);
|
||||
3. 海岸线侵蚀直至陆地占比接近 `landPercentage`;
|
||||
4. 顺坡开凿河流(含湖泊概率与已有的汇流判定);
|
||||
5. 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
|
||||
6. 按参考工程的生物群系表写地形类型与植被等级。
|
||||
|
||||
可复现性只依赖 `settings.seed` 与网格本身:不使用时间、`random_device` 或可变静态
|
||||
状态。`elevationMaximum` 会被钳制到 `HexMetrics::kMaxElevation`;不合法或放不下的
|
||||
区域参数在改动地图之前就以 `InvalidArgument` 拒绝。生成后的地图**全部格子未探索**,
|
||||
因此调用方必须重新放置单位并重建全部块。
|
||||
|
||||
### 搜索与寻路([HexSearch.h](../../../src/modules/hexmap/HexSearch.h))
|
||||
|
||||
- `HexSearchContext`:可复用的搜索草稿(每格一条 `HexSearchData` + 按优先级分桶
|
||||
的前沿队列)。用**相位**(`beginPhase()`,每次 +2)代替"清空 visited",因此一次
|
||||
搜索不付全图重置的代价。
|
||||
- `isValidDestination(map, c, occupied)`:`explored && explorable && !underwater &&
|
||||
!occupied`;地形部分由 `HexCell.h` 的 `canHoldUnit()` 单点定义。
|
||||
- `moveCost(map, from, to, direction, occupied)`:悬崖与墙界不可通行;起点有路 = 1,
|
||||
否则平地 5 / 斜坡 10,再加目标格的 urban+farm+plant 等级。返回**负数**表示阻断。
|
||||
- `findPath(map, scratch, from, to, rules, occupied) -> Result<HexPath>`:A\*(启发式
|
||||
为六边形距离),代价按 `speed` 折算成"回合",`HexPath::turns` 给出每个格子到达
|
||||
时的回合数。目标不可进入时返回 `NotFound`,端点越界或 scratch 尺寸不匹配返回
|
||||
`InvalidArgument`。
|
||||
- `collectVisibleCells(map, scratch, from, range, out)`:视线可达格(距离 + 目标格
|
||||
`viewElevation` 与 `range + 起点 viewElevation` 比较,且不超过直接六边形距离)。
|
||||
|
||||
### 可见性与迷雾([HexVisibility.h](../../../src/modules/hexmap/HexVisibility.h))
|
||||
|
||||
`HexVisibility` 是**引用计数**而非布尔:多个观察者重叠时,只有最后一个离开才让格子
|
||||
转回不可见;某格第一次被看见时同时把地图上的 `explored` **单向闩锁**置位,此后不再
|
||||
回落。`increase` / `decrease` / `clear` / `resetVisibility`(按当前单位重算全图)。
|
||||
|
||||
### 单位([HexUnits.h](../../../src/modules/hexmap/HexUnits.h))
|
||||
|
||||
`HexUnitRegistry` 持有单位、寻路占用与视野贡献:
|
||||
|
||||
- `addUnit` 立即授予视野;`unitIdAt` / `isOccupied` 用于寻路占用谓词
|
||||
(`occupancyQuery()` 返回借用 `*this` 的可调用对象)。
|
||||
- 单位 id 就是注册表下标,删除会让后面的 id 前移;调用方不要跨删除缓存 id。
|
||||
- 移动沿二次贝塞尔曲线(Unity 参考工程的转角做法),`advance(map, visibility,
|
||||
scratch, id, dt)` 既推进动画又返回**当前位姿样本**(世界坐标已含高程与扰动),
|
||||
`dt <= 0` 表示只读不推进。**目的地自 `beginTravel` 起即被占用**,视野则跟着
|
||||
实际走过的格子移动(`visionIndex`)。
|
||||
- `restore` 先清空再校验,被拒绝的载荷不会留下"半恢复"的注册表。
|
||||
|
||||
### 存档([HexSerializer.h](../../../src/modules/hexmap/HexSerializer.h))
|
||||
|
||||
`saveHexMap(map, units, out)` / `loadHexMap(bytes, map, units)`:小端、显式逐字节
|
||||
写出的自描述载荷(magic `EVEHEX\0\0` + 版本 + 尺寸 + 种子 + 每格 `HexValues` /
|
||||
`HexFlags` + 单位表)。版本不匹配会**拒绝**而不是猜测;载荷在触碰输出前整体校验
|
||||
(长度、magic、版本、尺寸上限 512 且为 5 的倍数、单位格在界内、单位格可站立、
|
||||
单位不重复),因此被拒绝的载荷不会留下部分写入的地图。修订号字段只作诊断。
|
||||
|
||||
### 球面后端([HexSphereTopology.h](../../../src/modules/hexmap/HexSphereTopology.h)、[HexSphereMap.h](../../../src/modules/hexmap/HexSphereMap.h)、[HexSphereMesh.h](../../../src/modules/hexmap/HexSphereMesh.h)、[HexSphereGenerator.h](../../../src/modules/hexmap/HexSphereGenerator.h))
|
||||
|
||||
前面所有小节都是**平面**地图:单元身份是 `HexCoordinates`,邻居是固定的整数方向偏移。
|
||||
球面后端复用同一套单元记录(`HexCellData` / `HexValues` / `HexFlags`)、同一套几何常量
|
||||
(`HexMetrics`)、同一个 `HexMeshData` 与同一份顶点编码,只替换这两件事——因为在闭合
|
||||
球面上它们才真正不成立:
|
||||
|
||||
- **单元身份**是稠密的 `HexSphereCell`(`int32`),来自**二十面体(Goldberg)拓扑**:
|
||||
二十面体按 `f = 2^subdivision` 细分后的**顶点**就是单元,于是
|
||||
`cellCount = 10f² + 2`,其中恰好 12 个是五边形(恒为 `[0, 12)`,因为中点细分从不
|
||||
重编号原始顶点);`edgeCount = 30f²`,`cornerCount = 20f²`,每个角点恰好被 3 格共享。
|
||||
- **邻居关系**来自半边表。**方向不是算术**:六条边的方位逐格变化,而五边形只有五条边,
|
||||
因此不存在处处成立的 `opposite(direction)`——所有跨边查询走
|
||||
`directionOf(cell, other)`。方向 `d` 的边位于 `corner(d)` 与 `corner(d+1)` 之间,
|
||||
这两个角点由该边两侧的单元**以相反顺序**共享。
|
||||
|
||||
`HexSphereMap` 是 `HexMap` 的球面对应物:持有单元存储、`HexNoise`、**逐格**脏标记
|
||||
(球面没有分块网格,拓扑已经给每格一个全局稳定 id)、拾取、距离、刷子与全套编辑接口。
|
||||
高程是**径向**的:`surfaceRadius = sphereRadius + elevation · elevationStep`。
|
||||
`elevationStep` 默认取半径的 `0.006`,而不是平面的 `HexMetrics::kElevationStep`
|
||||
(3 世界单位)——后者在半径 100 的星球上会让 13 级高程跨越 39% 半径。`HexFlags` 的
|
||||
道路/河流 6 个 bit 槽直接由方向索引寻址,五边形上槽 5 永不置位。
|
||||
|
||||
`buildSphereTerrainMesh` / `buildSphereWaterMesh` 沿用平面构建器的角点矩阵方案,顶点
|
||||
编码(`HexTerrainVertexCode`)完全一致,因此地形着色器的读法不变。三处平面假设被替换:
|
||||
|
||||
1. **边数**不是 6(五边形是 5 条),没有任何循环可以写死 `kHexDirectionCount`;
|
||||
2. **边归属**:平面版靠分块网格(`direction <= SE`)保证每条共享边只发一次,球面改用
|
||||
**较小的单元 id** 拥有该边——同一组边上的全序,同样保证恰好一次;
|
||||
3. **"垂直"是径向**:坡度台地沿**弧**走水平步、沿**半径**走垂直步。
|
||||
|
||||
平面版的梯田带用弦 `v1 → v5` 跨越整条边,这在平面上安全**只因为**它的 5 个采样点共线;
|
||||
球面上采样点在弧上,弦会在每条带坡度或崖壁的边留下 T 型接缝,因此球面版按扇形同样的
|
||||
4 段拆分。地形顶点扰动也改为**切向**并把结果投影回原半径:切向偏移会以 `O(d²/r)` 改变
|
||||
半径,而这里扰动量是单元尺寸的可观比例。
|
||||
|
||||
`generateSphereMap` 采样**三维**值噪声:`HexNoise` 是 XZ 平面场,用单元方向去采样会在
|
||||
两极塌缩(方向的 XZ 投影趋零)、在经度回绕处接缝。海平面由噪声自身的分位数确定,因此
|
||||
`landPercentage` 真正决定陆地占比,而不是听任噪声分布。输出是
|
||||
`(seed, 拓扑, 半径)` 的纯函数。
|
||||
|
||||
## 脚本 API(`hexmap`)
|
||||
|
||||
```squirrel
|
||||
hexmap.newGrid(gfx, cellCountX, cellCountZ, seed) // -> Result
|
||||
hexmap.hasGrid() / cellCountX() / cellCountZ() / chunkCountX() / chunkCountZ() / chunkCount() / seed()
|
||||
hexmap.surfaceCount() // -> 7,见下面的 surface 编号表
|
||||
|
||||
hexmap.takeDirtyChunk() // -> 脏块索引,-1 表示没有
|
||||
hexmap.rebuildChunk(gfx, chunkIndex) // -> 生成出几何的面数
|
||||
hexmap.rebuildDirtyChunks(gfx) // -> 重建全部脏块,返回面数合计
|
||||
hexmap.chunkMeshAt(chunkIndex, surface) // -> Mesh(借用),无几何时为 null
|
||||
hexmap.releaseMeshes(gfx) // 释放本模块创建的全部网格
|
||||
|
||||
hexmap.elevation(x, z) / waterLevel(x, z) / terrainType(x, z) / urbanLevel(x, z) / ...
|
||||
hexmap.isUnderwater(x, z) / hasRiver(x, z) / hasRoad(x, z) / isWalled(x, z)
|
||||
hexmap.specialIndex(x, z) / farmLevel(x, z) / plantLevel(x, z)
|
||||
hexmap.setElevation(x, z, v) / setWaterLevel(x, z, v) / setTerrainType(x, z, v) / ...
|
||||
hexmap.setUrbanLevel(x, z, v) / setFarmLevel(x, z, v) / setPlantLevel(x, z, v) / setSpecialIndex(x, z, v)
|
||||
hexmap.setWalled(x, z, v) / setOutgoingRiver(x, z, direction) / removeRiver(x, z)
|
||||
hexmap.addRoad(x, z, direction) / removeRoads(x, z)
|
||||
hexmap.editElevation(x, z, radius, delta) / editWaterLevel(x, z, radius, delta)
|
||||
hexmap.editTerrainType(x, z, radius, terrainType) / editFeatureLevel(x, z, radius, feature, delta)
|
||||
|
||||
// 世界坐标(含高程与扰动);同一坐标系的逐分量版本,供脚本直接喂给 renderable
|
||||
hexmap.cellPositionX(x, z) / cellPositionY(x, z) / cellPositionZ(x, z)
|
||||
hexmap.chunkCenterX(chunkIndex) / chunkCenterY(chunkIndex) / chunkCenterZ(chunkIndex)
|
||||
hexmap.pickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=[x, z]}
|
||||
|
||||
// 迷雾
|
||||
hexmap.isExplored(x, z) / isExplorable(x, z) / setExplored(x, z, v) / setExplorable(x, z, v)
|
||||
hexmap.isCellVisible(x, z) / visibleCellCount() / resetVisibility()
|
||||
|
||||
// 单位
|
||||
hexmap.unitCount() / addUnit(x, z, orientation) / removeUnit(id) / removeAllUnits()
|
||||
hexmap.unitIdAt(x, z)
|
||||
hexmap.unitSample(id) // -> {ok, value=[cellX, cellZ, worldX, worldY, worldZ, yaw, traveling]}
|
||||
hexmap.travelUnit(id, path) / advanceUnits(dt)
|
||||
|
||||
// 寻路 / 存档 / 生成
|
||||
hexmap.findPath(fx, fz, tx, tz) // -> {ok, value=[[x, z, turn], ...]}
|
||||
hexmap.saveMap() // -> {ok, value=<二进制安全字符串>}
|
||||
hexmap.loadMap(gfx, blob) // -> Result
|
||||
hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // -> Result
|
||||
|
||||
// 球面(与平面地图相互独立,可同时存在)
|
||||
hexmap.newSphere(gfx, subdivision, radius, seed) // -> Result;重建拓扑并释放上一颗星球的网格
|
||||
hexmap.sphereReady() / sphereCellCount() / spherePentagonCount() / sphereSubdivision()
|
||||
hexmap.sphereRadius() / sphereElevationStep() / sphereCellSpacing()
|
||||
hexmap.sphereIsPentagon(cell) / sphereNeighborCount(cell) / sphereNeighbor(cell, direction)
|
||||
hexmap.sphereDirection(cell) / sphereCornerDirection(cell, corner) / sphereCornerCount(cell)
|
||||
hexmap.sphereElevation(cell) / sphereWaterLevel(cell) / sphereTerrainType(cell) / sphereIsUnderwater(cell)
|
||||
hexmap.sphereCellAt(x, y, z) / sphereDistance(a, b)
|
||||
hexmap.spherePickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=<cell>}
|
||||
hexmap.sphereSetElevation(cell, v) / sphereSetTerrainType(cell, v)
|
||||
hexmap.sphereEditElevation(cell, radius, delta) / sphereEditTerrainType(cell, radius, terrainType)
|
||||
hexmap.generateSphere(gfx, seed, landPercentage, waterLevel) // -> Result;生成并重建两个网格
|
||||
hexmap.rebuildSphere(gfx) // -> Result;批量编辑后重建
|
||||
hexmap.sphereTerrainMesh() / sphereWaterMesh() // -> Mesh(借用),无几何时为 null
|
||||
hexmap.sphereReleaseMeshes(gfx)
|
||||
```
|
||||
|
||||
`surface` 取 `0=Terrain, 1=Water, 2=River, 3=Road, 4=Fog, 5=Wall, 6=Feature`。
|
||||
`x, z` 是偏移坐标(列、行)——`pickCell` 的返回值同样是偏移坐标,与 `elevation` /
|
||||
`editElevation` / `cellPositionX` 等所有格子接口同一坐标系,因此拾取结果可以直接回喂
|
||||
给它们(`findPath` 与 `unitSample` 返回的格子也已经是偏移坐标)。`unitSample` 复用
|
||||
`advance(dt=0)`,因此返回的是含高程与扰动的插值位姿,不是地格中心。`generateMap` 按
|
||||
**当前网格尺寸**重新生成,会丢弃全部单位与迷雾状态,调用方必须在成功后重新放置单位
|
||||
并重建全部块。
|
||||
|
||||
球面接口用 `cell`(稠密 id,`0 .. sphereCellCount()-1`),与平面接口的偏移坐标 `(x, z)`
|
||||
是**两套互不相通的地址空间**:不要拿平面坐标去喂球面接口,反之亦然。`sphereDirection`
|
||||
与 `sphereCornerDirection` 返回 `[x, y, z]` 单位向量。`newSphere` 只重建拓扑,
|
||||
`generateSphere` 才写入单元并重建网格;两者都会释放上一颗星球的网格,因此调用方必须在
|
||||
成功后重新绑定 renderable。`rebuildSphere` 是批量编辑后的重建入口——球面是**整张一个
|
||||
网格**(没有分块),每次重建都是全量,应当攒够一批编辑再调一次,而不是每改一格就调。
|
||||
|
||||
## 生命周期与所有权
|
||||
|
||||
- 模块**只持有一张活动地图**(参考工程的编辑器同样只编辑一张格子);`newGrid`
|
||||
会释放上一张地图生成的全部 GPU 网格。
|
||||
- 模块拥有 `HexMap` 与它创建的每个 `graphics::Mesh`;`chunkMeshAt` 返回**借用**
|
||||
指针,`Renderable3D` 只能在模块存活且未换图期间引用它。
|
||||
- 某面在一张地图内可能从"有几何"变为"无几何"(例如最后一条道路被擦掉):此时
|
||||
该面的 GPU 网格**延迟释放**(保留到下次 `newGrid`),`chunkMeshAt` 返回
|
||||
`null`,调用方据此隐藏 renderable,不会出现悬垂指针。
|
||||
- `loadMap` 走 `adoptGrid`:先释放旧网格、接管已解码的地图、重建搜索草稿与迷雾
|
||||
计数器,再恢复单位。**调用方必须在成功后重建全部块并重新绑定 renderable**,
|
||||
因为所有块都是脏的且没有任何 renderable 指向新网格。
|
||||
- 主/渲染线程亲和;不调用未知回调,不跨外部调用持锁。
|
||||
|
||||
## 示例
|
||||
|
||||
`examples/hex-terrain-3d` 是本模块的完整示例(与之无关的 `examples/hex-terrain` 是
|
||||
`mesh.hexterrain` 配方,保持原样):`WASD` 平移、`Q/E` 旋转、滚轮缩放,
|
||||
`1`–`5` 切换高程/水面/地形/河流/道路模式,`[`/`]` 调整笔刷半径,`,`/`.` 切换地形
|
||||
调色板,左右键升降或拖拽绘制河流与道路,`R` 换种子、`F` 换地图尺寸、`N` 重建。
|
||||
第二轮补上战争迷雾、单位与寻路、存档:`tab` 切换选中单位,`g` 规划到光标格的路径,
|
||||
`space` 出发,`escape` 取消预览,`t`/`y` 增删单位,`F5`/`F9` 存档与读档。第三轮补上
|
||||
城墙/桥梁/地物与地图生成器:`m` 按当前尺寸生成一张程序化地图(重新随机种子)。
|
||||
|
||||
存档载荷经 `eve.Filesystem().writeText()` 落到保存目录,读回时先整体校验再接管
|
||||
网格,因此损坏的文件不会破坏当前地图。
|
||||
|
||||
`examples/hex-planet` 是**球面后端**的示例:轨道相机绕星球旋转,`LMB` 拖拽改变
|
||||
方位角/仰角,滚轮缩放,`space` 自动旋转,`R` 换种子,`[`/`]` 切换细分级别,`-`/`=`
|
||||
调整陆地占比,`F5` 抓帧。默认姿态是静止的,所以两次运行抓到的画面几乎逐像素
|
||||
一致(仅 ImGui 覆盖层有像素级差异)。它自带两个片元着色器:平面地形着色器把
|
||||
`vWorldPos.y` 当海拔、`N.y` 当"上"、细节噪声投影到 XZ、远处用距离雾——这四件事在球面上
|
||||
都不成立,因此球面版保留调色板与 BRDF、只替换这四处。另外宿主会在片元输出之后再套一层
|
||||
Reinhard 风格的 tonemap(调试着色器输出常量 `1.0` 到达帧缓冲是 `224/255`,即
|
||||
`y = x/(x+0.139)`),两个球面着色器都用 `hostTonemapInverse` 反解这条曲线,否则颜色会被
|
||||
压缩两次且无法调参。
|
||||
|
||||
相机方面示例做了一个必要的修正:`eve.Camera3D()` 的默认裁剪范围是 `0.1..100`,
|
||||
比一张地图的跨度还小,超过 100 世界单位的地形会被静默裁掉,远端的缩放档位因此只
|
||||
能看到空屏。示例现在每帧按地图跨度推导远裁剪面,并让最远缩放距离随之放大
|
||||
(`ZOOM_FAR_MARGIN`),所以任何地图尺寸都能完整入画。
|
||||
|
||||
## 已知边界(尚未实现)
|
||||
|
||||
瀑布与河口(estuary)几何尚未移植;河道是等宽中心线带,未做 7 分支交汇解算;迷雾按
|
||||
"已探索 / 当前可见"两态渲染(不区分"当前可见"之外的中间态透明度),且迷雾柱顶取
|
||||
自身与邻居的最高面,因此在高差极大的边界上会比参考工程略"鼓";地物是程序化几何
|
||||
而非参考工程的 prefab 比例。
|
||||
|
||||
水面与参考工程逐方向对齐:邻居是水就发一个中心三角形 + 桥接四边形(桥接只由
|
||||
NE/E/SE 侧发,避免水/水边界重复),邻居是陆地就发四个细分楔形三角形 + 四段岸带;
|
||||
岸带**平铺在水面高度**,只借用邻格的实体角点平面位置,不借用它的高程——倾斜到岸顶的
|
||||
写法会在每条六边形边上留下一条亮带。`HexSurface::Water` 因此完全水平,测试
|
||||
`hexmap.mesh.waterSurfaceIsFlatAtAShore` 会断言所有水面顶点同高、所有法线朝 +Y。
|
||||
|
||||
地图生成器的两处刻意偏离参考工程(均已在源码中注释):侵蚀取共享搜索前沿中最低的
|
||||
格子,而参考工程是均匀随机挑选可侵蚀格;温度抖动使用私有的值噪声,而参考工程采样
|
||||
噪声贴图。生成器的 `HemisphereMode` 只实现参考工程的 `North` 映射。
|
||||
|
||||
球面后端的已知边界:球面**没有**河流、道路、城墙、地物、迷雾与单位的网格与查询接口
|
||||
(`HexFlags` 的位可以设置,但没有对应的球面几何与寻路);海洋是每格一个六边形盖面,
|
||||
没有浅滩、折射与岸线浪花,且按模块自身的规则 `waterLevel == elevation` 不算淹没,
|
||||
所以海平面上的格子会露出;崖壁是径向直墙,没有悬垂或侵蚀细节;球面生成器没有河流、
|
||||
侵蚀与板块构造——平面的 `HexMapGenerator` 没有移植过来。球面地图也**没有存档格式**。
|
||||
@@ -47,6 +47,13 @@ canvas.setPixel(10, 10, 1.0, 0.0, 0.0, 1.0);
|
||||
|
||||
返回同格式的新 `ImageData`(调用方拥有);源范围外采样为透明黑。`rotsprite` 只挑选已有调色板颜色,不引入插值新色。
|
||||
|
||||
`ImageData.scalePcg(width, height, filter)` 原地执行 Pcg `ScaleTexture` 的精确取样规则,
|
||||
`filter` 为 `"point"` 或 `"bilinear"`。Point 使用
|
||||
`floor(sourceSize / destinationSize * coordinate)`;Bilinear 使用
|
||||
`(sourceSize - 1) / destinationSize`,忠实保留其不会采到最后一行/列端点的行为。
|
||||
它返回结构化 Result,成功值为写入像素数;非法尺寸、滤镜或不足 2x2 的 Bilinear 源图
|
||||
不会改变原图。调用会替换当前 `ImageData` 的像素和尺寸;共享缓存图片应先 `clone()`。
|
||||
|
||||
## 目标导向指南
|
||||
|
||||
### 加载并上传一张 PNG 为纹理
|
||||
@@ -93,6 +100,7 @@ CPU 编辑器与 GPU Canvas 喷涂共享 `newUvPaintRegion()` 创建的可复用
|
||||
- `Image`:`getName()`、`newImageData(data)`、`newImageDataFromFile(path)`、`newEmptyImageData(width, height, format)`、`newUvPaintRegion()`、`isCompressed(data)`
|
||||
- `ImageData`:`getWidth()`、`getHeight()`、`getFormat()`、`getSize()`、`getPixelSize()`、`isSRGB()`、`inside(x, y)`、
|
||||
`clone()`、`paste(src, dx, dy, sx, sy, sw, sh)`、`rotate(radians, filter, expand)`、
|
||||
`scalePcg(width, height, filter)`、
|
||||
`getPixelR(x, y)`、`getPixelG(x, y)`、`getPixelB(x, y)`、`getPixelA(x, y)`、`setPixel(x, y, r, g, b, a)`、
|
||||
`paintCircleUv(u, v, radiusPixels, r, g, b, a, wrapU, wrapV)`
|
||||
- `UvPaintSession`:`initialize(image)`、`paintCircle(...)`、`undo()`、`restore()`、`bake()`、
|
||||
|
||||
@@ -398,6 +398,10 @@ embers.start();
|
||||
|
||||
`Particles` 管理 Emitter 及配置、模拟、渲染系统;Emitter 持有容量、发射配置与运行状态。模块 update 统一推进所有 emitter(含骨骼绑定同步与蒙皮表面采样),render 按 layer 提交。
|
||||
|
||||
`applyUnderwaterParticles(ambience,transition,active,transitionFx,entered,exited)` 将 Pcg 水下状态接到两个借用
|
||||
Emitter。ambience 在水下可见并运行、离水停止并隐藏;启用过渡效果且本帧 entered/exited 时,transition
|
||||
会停止、复位播放状态并重新启动。entered 与 exited 同时为真会在任何修改前被拒绝。
|
||||
|
||||
帧序建议:动画 `computeWorld` → `particles.update(dt)` → `particles.render(gfx)`。
|
||||
|
||||
## 目标导向指南
|
||||
@@ -445,3 +449,29 @@ embers.start();
|
||||
|
||||
**源码:** [`src/modules/particles/`](../../../src/modules/particles/)
|
||||
**相关测试:** [`test/particles.cpp`](../../../test/particles.cpp)、[`test/particles_effect_asset.cpp`](../../../test/particles_effect_asset.cpp)、[`test/particles_reference_effects.cpp`](../../../test/particles_reference_effects.cpp)、[`test/particles_attach_skin.cpp`](../../../test/particles_attach_skin.cpp)、[`test/particles_dynamic_bones.cpp`](../../../test/particles_dynamic_bones.cpp)、[`test/particles_attach_more.cpp`](../../../test/particles_attach_more.cpp)、[`test/particles_attach_extra.cpp`](../../../test/particles_attach_extra.cpp)。
|
||||
|
||||
`applyUnderwaterSurfaceVfx(surfaceVfx,active)` 对应 Pcg `SetHDRPVisualEffectsState` 的反向水下开关:水面时
|
||||
显示并启动调用者拥有的天气/VFX emitter,潜水时停止并隐藏。调用可对一组 surface emitter 逐个执行;函数
|
||||
同步借用对象且不保存指针,空对象在修改前返回失败。
|
||||
|
||||
`applyGroundParticleCulling(emitter,playerTag,visitorTag,entered,exited)` 对应 Pcg
|
||||
`GroundParticlesCulling` 的玩家触发式粒子开关。创建区域时,Box 触发器的完整尺寸使用 Pcg 的 `Radius`
|
||||
三轴值,Sphere 触发器直接使用 `Radius`;Shape 必须设为 sensor,并把稳定的玩家标签从 `World3D`
|
||||
begin/end 事件传给此函数。组件启用时先调用 `emitter.stop()`;匹配玩家标签的 enter 启动 emitter,exit
|
||||
停止 emitter,无关访客保持原状态。函数只在调用期间借用 emitter,不保存指针;空 emitter、零玩家标签,
|
||||
或同时设置 enter/exit 会在修改前返回失败。
|
||||
|
||||
`shiftWorldSpaceParticles(emitter,shiftX,shiftY)` 对应 Pcg
|
||||
`FloatingPointFixParticleSystem` 与 `TerrainLoaderManager.SetOrigin` 的存量粒子修正。调用者计算本次世界原点
|
||||
增量,并在下一次粒子 update/render 前调用;函数从所有存活粒子位置减去该增量,同时保留 emitter 位置、
|
||||
速度、寿命以及播放/暂停状态。返回值是本次覆盖的 CPU 与 GPU-resident 存活粒子总数。GPU 粒子位移会累计到
|
||||
下一次 resident update,提交成功后清零;若后端在提交前停用,待提交偏移也随其状态一同清除。
|
||||
只有 `simulationSpace="world"` 的 emitter 可调用,本地空间 emitter 和非有限增量会在修改前失败。对于把
|
||||
3D X/Z 投影到粒子 X/Y 平面的项目,参数 `shiftY` 对应世界 Z 增量;Pcg 的垂直世界 Y 原点保持不变。
|
||||
|
||||
### Pcg 随机粒子材质
|
||||
|
||||
`eve.PcgMaterialSelector()` 保存调用者提供的借用纹理列表。`add(texture)` 添加候选,`clear()` 清空,
|
||||
`count()` 返回数量;`selectAndApply(emitter,seed)` 使用显式 seed 确定性选择并应用到粒子发射器。为忠实对应
|
||||
Pcg `RandomMaterialSelector` 的 `Random.Range(1, materials.Count)`,索引 0 被保留且永不选择;因此至少
|
||||
需要两个非空候选。失败返回标准 `Result`,不会修改 emitter 原有纹理。
|
||||
@@ -1007,6 +1007,19 @@ world3.moveCapsule(ax, ay, az, bx, by, bz, radius, dx, dy, dz);
|
||||
- `isAngularYLocked()`、`isAngularZLocked()`、`isContinuousCollisionEnabled()`、`isLinearXLocked()`、`isLinearYLocked()`、`isLinearZLocked()`、`isSleepEnabled()`、`isWarmStartingEnabled()`
|
||||
- `sample()`、`sampleNormal()`、`setDistanceLength()`、`setSleepThreshold()`
|
||||
|
||||
## 等待流送地形后激活刚体
|
||||
|
||||
`TerrainLoadGravityState`、`beginTerrainLoadGravity(state,body,terrainFound)` 和
|
||||
`advanceTerrainLoadGravity(state,body,terrainLoaded,dt,activationDelay)` 对应 Pcg
|
||||
`RigidbodyWaitForTerrainLoad`。调用者先用世界流送索引判断刚体位置是否落在某个 terrain scene 内;存在时
|
||||
begin 保存原 gravity scale 并临时设为 0,不存在时直接进入 completed 且不修改刚体。每帧注入当前 regular
|
||||
load state 和 dt;首次观察到 loaded 后启动延时,延时到期时恢复原 gravity scale,并返回 `value=true`
|
||||
一次,调用者可据此启用自己拥有的附属组件。
|
||||
|
||||
这项状态机有意保留 Pcg `Invoke` 的语义:延时一旦排定,之后短暂的 unloaded 观察不会取消激活。状态与
|
||||
Body3D 都由调用者持有,函数不保存指针、不调用未知回调;无效对象、负数或非有限时间会在修改前失败。
|
||||
`isMonitoring()`、`isActivationScheduled()`、`isCompleted()` 和 `getDelayElapsed()` 可用于存档外的运行诊断。
|
||||
|
||||
## 使用要点
|
||||
|
||||
- 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Procgen Animation
|
||||
|
||||
`procgen_animation` 是程序化网格与骨骼动画之间的可选 L6 组合模块。运行时入口为
|
||||
`eve.procgenAnimation`;基础 `procgen` 与 `animation` 模块仍可独立裁剪。
|
||||
|
||||
## Pcg Skinned MeshCombiner
|
||||
|
||||
用 `PcgSkinnedMeshPlan.appendSource(mesh, skin, transform, defaultMaterialId)` 按 renderer 顺序复制输入,随后调用
|
||||
`eve.procgenAnimation.combinePcgSkinnedMeshes(output, plan)`。成功后 `output.copyMesh()` 返回拥有式组合网格,
|
||||
`output.getSkin()` 返回与该网格顶点顺序匹配、由 output 拥有的 `AnimSkin`。output 下一次组合或销毁后,该 skin
|
||||
借用指针失效。
|
||||
|
||||
joint 仅在 skeleton bone identity 与 16 元素 inverse bind 同时相同时复用;四个 influence 槽按 Pcg 原算法
|
||||
重映射。组合和 skin 构造全部成功后才替换 output,失败不会留下只有 mesh 或只有 skin 的半完成状态。
|
||||
`PcgSkinnedMeshPlan.getSourceCount()` 返回输入数并可用 `clear()` 清空;组合结果以 `isValid()` 查询是否同时
|
||||
持有可用网格与 skin。
|
||||
@@ -0,0 +1,14 @@
|
||||
# Procgen Physics
|
||||
|
||||
`procgenPhysics <- eve.ProcgenPhysics()` 是程序化地形与 3D 物理之间的可选组合模块。
|
||||
运行时裁剪配置不需要物理时可以从构建 profile 中移除该模块。
|
||||
|
||||
`procgenPhysics.newGtsTerrainColliderRuntime()` 创建 caller-owned
|
||||
`GtsTerrainColliderRuntime`。`replace(lods,world,collisionLevel,originX,originY,originZ)` 使用指定 LOD 的
|
||||
MeshBuild 为每个非空 tile 创建静态 `Body3D` 和凹三角网格 Shape3D。Body 的位置为 terrain origin 加 tile
|
||||
pivot offset,因此碰撞面和 GTS renderable 使用同一世界变换。
|
||||
|
||||
`replace` 先完成全部候选 collider,随后一次替换旧批;失败时旧 body、tile 槽和 revision 保持不变。
|
||||
`getBody(tile)` 返回借用的 live body,空槽、失效句柄或已销毁 world 返回 null。`getTileCount()` 包含空 tile,
|
||||
`getRevision()` 返回已提交版本,`clear()` 销毁仍存活的 body 并返回数量。Runtime 只保存
|
||||
`PhysicsWorldHandle` 与 `PhysicsBodyHandle`;World3D 先销毁时这些 link 自动变 stale。
|
||||
@@ -145,6 +145,202 @@ if (world.isObjectConsumed("village", "old_chest")) { /* 不再生成宝箱 */ }
|
||||
缺失括号、尾随文本、非法 token 与非有限数值会拒绝注册;C++
|
||||
`evaluateFormulaChecked()` 在运行时除零返回结构化失败,兼容求值入口投影为 `0`。
|
||||
|
||||
## `.dnut` 剧情脚本(RPG 方言)
|
||||
|
||||
`.dnut` 是引擎的作者序列语言。**RPG 方言**由 `rpg` 模块注册词表,用一份文本描述
|
||||
剧情事件的移动、动画、选取、技能、属性、物品与装备。语言核心(词法、编译、
|
||||
跨帧解释器)在 L1 模块 `dnut_interpreter`,RPG 只贡献**词表**与领域 handler。
|
||||
|
||||
### 最小例子
|
||||
|
||||
```
|
||||
story village.arrival {
|
||||
move actor=hero x=10 y=4 duration=0.5
|
||||
animation target=hero clip=emote.wave hold=true
|
||||
skill actor=hero learn=fireball
|
||||
attribute actor=hero name=attack op=add value=5
|
||||
item add=potion count=2
|
||||
equipment actor=hero equip=iron-sword slot=weapon
|
||||
flag name=village.visited state=true
|
||||
message text=Arrived
|
||||
}
|
||||
```
|
||||
|
||||
### 步骤词表
|
||||
|
||||
| 步骤 | 字段 | 形状 | 谁执行 |
|
||||
| --- | --- | --- | --- |
|
||||
| `move` | `actor` `x` `y` `[duration]` | 宿主呈现 / 移动控制器 | `RpgStoryBinding::moveActor`;未装控制器时由呈现层执行,脚本确认 |
|
||||
| `animation` | `target` `clip` `[loop]` `[hold]` | 宿主呈现 / 播放控制器 | `RpgStoryBinding::playAnimation`;未装控制器时由呈现层执行,脚本确认 |
|
||||
| `select` | `object` `[prompt]` | 宿主呈现 | 交互层 |
|
||||
| `camera` | `x` `y` `[duration]` | 宿主呈现 | 相机 |
|
||||
| `dialogue` | `id` | 宿主呈现 | dialogue 模块 |
|
||||
| `message` | `text` | 宿主呈现 | UI |
|
||||
| `skill` | `actor` + (`learn` \| `forget`) | 立即 | `RPGActor::learnSkill` / `forgetSkill` |
|
||||
| `attribute` | `actor` `name` `op(set\|add\|sub\|mul)` `value` | 立即 | `RPGActor` 基础属性 |
|
||||
| `item` | (`add` \| `remove`) `[count=1]` | 立即 | `inventory::Bag` |
|
||||
| `equipment` | `actor` + (`equip` `slot` \| `unequip`) | 立即 | `inventory::EquipmentSet` |
|
||||
| `flag` | `name` `state` | 立即 | `GameState` 开关 |
|
||||
| `variable` | `name` `op(set\|add)` `value` | 立即 | `GameState` 变量 |
|
||||
|
||||
控制流:`if <条件> { … } else { … }`、`choice { option "标签" [when <条件>] { … } }`、
|
||||
`call <storyId>`、`wait <秒>`、`end`。
|
||||
|
||||
条件是 `变量 运算符 字面量`,可用 `&&`、`||`、`!` 和括号组合。变量名用点号寻址
|
||||
`GameState`:`switch.<名>`、`variable.<名>`、`self.<scope>.<名>`。
|
||||
|
||||
### 角色移动(跨游戏模式)
|
||||
|
||||
`.dnut` 只声明「谁去哪里、用多久」,**不声明怎么走**。`x` / `y` 的含义由宿主定义:
|
||||
格子 RPG 当作格坐标,战棋当作棋盘格,连续世界当作世界单位,横版当作车道。引擎
|
||||
既不解释也不取整,更不假设任何寻路模型,所以同一份剧情脚本可以同时服务不同玩法。
|
||||
|
||||
宿主在 binding 上装一个 `moveActor`,`move` 步骤就自动交给它,不必再在宿主循环里
|
||||
写 `if (kind == "move")` 分支:
|
||||
|
||||
```cpp
|
||||
eve::rpg::RpgStoryBinding binding;
|
||||
binding.party = &party;
|
||||
binding.gameState = &gameState;
|
||||
binding.moveActor = [](const eve::rpg::StoryMoveRequest& request) {
|
||||
// 你的移动模型:格子 / 战棋 / 连续世界都在这里决定。
|
||||
return navigator.request(request.actorId, request.x, request.y,
|
||||
request.hasDuration, request.duration);
|
||||
};
|
||||
```
|
||||
|
||||
三种返回值决定剧情如何继续:
|
||||
|
||||
| 返回值 | 效果 |
|
||||
| --- | --- |
|
||||
| `StoryMoveStatus::Arrived` | 步骤**立即完成**,剧情继续,不挂起(瞬移,或角色已在目标点) |
|
||||
| `StoryMoveStatus::Travelling` | 剧情挂起,宿主在角色**真正到达后**调用 `session.advance()` |
|
||||
| `StoryMoveStatus::Unreachable` | 步骤失败,返回结构化诊断,游标停在原地 |
|
||||
|
||||
控制器返回失败 `Result` 时诊断原样上报,因此「寻路服务没加载」这类原因不会被吞掉。
|
||||
|
||||
**不装控制器时行为完全不变**:`move` 仍是宿主呈现步骤,脚本读 payload 后 `advance()`。
|
||||
这是给「移动必须走自己的命令队列」这类宿主留的出口,也是既有内容的兼容路径。
|
||||
|
||||
`StoryMoveRequest::actor` 只是便利投影:仅当 binding 能解析该 id(`party` 或
|
||||
`resolveActor`)时才非空。地图物件、相机、编队等非队伍主体会让它为 null,而步骤
|
||||
照样执行——用 `actorId` 在自己的主体空间里解析即可。
|
||||
|
||||
### 动画播放(跨游戏模式)
|
||||
|
||||
`.dnut` 只声明「谁播哪个 clip、要不要循环、结束后是否定格」,**不声明怎么播**。
|
||||
`clip` 对引擎是**不透明字符串**:骨骼动画 clip id、Sprite 序列名、Spine 动画、
|
||||
montage 段都可以。引擎既不解析也不加载它,更不假设任何骨骼或动画栈,所以同一份
|
||||
剧情脚本可以同时服务不同玩法。
|
||||
|
||||
宿主在 binding 上装一个 `playAnimation`,`animation` 步骤就自动交给它,不必再在
|
||||
宿主循环里写 `if (kind == "animation")` 分支:
|
||||
|
||||
```cpp
|
||||
eve::rpg::RpgStoryBinding binding;
|
||||
binding.party = &party;
|
||||
binding.gameState = &gameState;
|
||||
binding.playAnimation = [](const eve::rpg::StoryAnimationRequest& request) {
|
||||
// 你的播放模型:骨骼 / Sprite / Spine / montage 都在这里决定。
|
||||
return player.play(request.targetId, request.clip, request.loop, request.hold);
|
||||
};
|
||||
```
|
||||
|
||||
三种返回值决定剧情如何继续:
|
||||
|
||||
| 返回值 | 效果 |
|
||||
| --- | --- |
|
||||
| `StoryAnimationStatus::Finished` | 步骤**立即完成**,剧情继续,不挂起(瞬发、零长 clip、纯表现性闪烁) |
|
||||
| `StoryAnimationStatus::Playing` | 剧情挂起,宿主在 clip **真正播完后**调用 `session.advance()` |
|
||||
| `StoryAnimationStatus::Unavailable` | 步骤失败,返回「谁播不了哪个 clip」的结构化诊断,游标停在原地 |
|
||||
|
||||
控制器返回失败 `Result` 时诊断原样上报,因此「clip 库还在流式加载」这类原因不会被吞掉。
|
||||
|
||||
`loop` 与 `hold` 按作者书写的原样透传,引擎不解释:`loop` 表示无限重复,`hold` 表示
|
||||
clip 结束后停在最后一帧。宿主如果没有对应概念,忽略自己没实现的标志即可——省略这两个
|
||||
字段时它们都是 `false`,不会替宿主做任何猜测。
|
||||
|
||||
**不装控制器时行为完全不变**:`animation` 仍是宿主呈现步骤,脚本读 payload 后
|
||||
`advance()`。这是给「播放必须走自己的状态机 / 混合调度」这类宿主留的出口。
|
||||
|
||||
`StoryAnimationRequest::actor` 与移动的 `actor` 一样只是便利投影:仅当 binding 能解析
|
||||
该 id(`party` 或 `resolveActor`)时才非空。特效挂点、地图道具、UI 元素等非队伍主体会让
|
||||
它为 null,而步骤照样执行——用 `targetId` 在自己的主体空间里解析即可。
|
||||
|
||||
### 使用
|
||||
|
||||
下面的脚本走的是**未装 C++ 移动控制器**的宿主呈现路径:`move` 怎么做完全由脚本
|
||||
决定,这正是「脚本控制角色移动」的推荐写法,也是跨玩法最灵活的一条路。
|
||||
|
||||
```squirrel
|
||||
local rpg = getModInst("rpg");
|
||||
rpg.replaceStoriesFromDnut(fileContent, "village.dnut"); // 严格校验 + 原子替换
|
||||
|
||||
local session = rpg.newStorySession();
|
||||
local result = session.begin("village.arrival", gs, party, bag, equipment);
|
||||
if (!result.ok) { print(result.status.summary + "\n"); return; }
|
||||
|
||||
while (session.isActive()) {
|
||||
local kind = session.getStepKind();
|
||||
if (kind == "move") {
|
||||
// 呈现层负责移动;完成后确认
|
||||
moveActor(session.getStepString("actor"), session.getStepNumber("x"), session.getStepNumber("y"));
|
||||
session.advance();
|
||||
} else if (kind == "choice") {
|
||||
session.select(session.getChoiceLabel(0));
|
||||
} else if (kind == "message") {
|
||||
showMessage(session.getStepString("text"));
|
||||
session.advance();
|
||||
} else {
|
||||
session.advance();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 脚本面(Squirrel)
|
||||
|
||||
`eve.rpg` 上的方法:
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `replaceStoriesFromDnut` | 严格编译并原子替换 `.dnut` 故事目录 |
|
||||
| `clearStories` | 清空已发布的故事 |
|
||||
| `getStoryCount` | 已发布故事数量 |
|
||||
| `hasStory` | 某个 story id 是否存在 |
|
||||
| `newStorySession` | 新建一个 `RPGStorySession` |
|
||||
|
||||
`RPGStorySession` 上的方法:
|
||||
|
||||
| 方法 | 作用 |
|
||||
| --- | --- |
|
||||
| `begin` | 启动一个 story,运行到第一个挂起点;`party` / `bag` / `equipment` 可传 `null`(纯演出 story 不需要它们) |
|
||||
| `advance` | 确认当前宿主呈现的步骤并继续 |
|
||||
| `select` | 按标签回答 `choice` 步骤 |
|
||||
| `stop` | 停止并清空会话 |
|
||||
| `isActive` | 是否有 story 正在运行 |
|
||||
| `isBlocked` | 是否挂起等待确认 |
|
||||
| `getStoryId` | 当前 story id |
|
||||
| `getStepKind` | 当前步骤类型 |
|
||||
| `getChoiceLabelCount` | 当前 `choice` 的选项数 |
|
||||
| `getChoiceLabel` | 按序号取选项标签 |
|
||||
| `getStepString` | 取当前步骤的字符串字段 |
|
||||
| `getStepNumber` | 取当前步骤的数值字段 |
|
||||
| `getStepBool` | 取当前步骤的布尔字段 |
|
||||
|
||||
### 语义要点
|
||||
|
||||
- **严格编译**:字段名、字段类型、跨字段规则(`learn` 与 `forget` 恰有其一)在
|
||||
`replaceStoriesFromDnut` 时报错;含错误的文档**不替换**上一份目录。
|
||||
- **宿主呈现的步骤**没有 C++ handler:运行时会挂起,脚本必须 `advance()`;
|
||||
`choice` 必须用 `select(标签)` 回答,`advance()` 会被拒绝。
|
||||
`move` 在装了移动控制器时由 handler 派发,但**确认方式相同**——角色到达后
|
||||
调用 `advance()`;控制器返回 `Arrived` 时根本不会挂起。
|
||||
- **领域步骤**在运行时立即执行;失败(例如袋中不足)会让 `begin`/`advance` 返回
|
||||
结构化失败并保持游标,不会静默跳过。
|
||||
- **完成事实**:不可重复的 story 完成后把 `story.<id>.completed` 写进 `GameState`,
|
||||
再次 `begin` 会失败;`repeatable` story 每次重新开始。
|
||||
- **快照**:`begin` 时快照整份目录,热替换不会让活动会话悬空。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- 只注册 Skill 未注册其引用的 Effect。
|
||||
|
||||
@@ -191,6 +191,8 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
|
||||
- `getNodeCount()`、`getNodePath()`、`getParentId()`、`getRootId()`、`hasNode()`、`isAncestor()`、`isDescendant()`、`linkRenderable2D()`
|
||||
- `linkRenderable3D()`、`mountBuild()`、`mountBuildAs()`、`remountBuildAs()`、`select()`、`setBuildPosition()`、`setBuildRotation()`、`setBuildScale()`
|
||||
- `setBuildSpace()`、`setBuildVisible()`、`setHostLayer()`、`setHostVisible()`、`setNodePosition()`、`setNodeRotation()`、`setNodeScale()`、`setNodeVisible()`
|
||||
|
||||
Pcg `Growth` 由 `eve.PcgGrowth()` 承接。`configure(startScale,endScale,variance,growthTime)` 配置范围,`start(realtime,seed)` 选择确定性的实际终点;`advance(realtime)` 返回统一缩放,`applyPcgGrowth(growth,node,realtime)` 将其应用到真实 `SceneNodeRef`,并在 `die(realtime)` 后五秒删除节点。状态可用 `getActualEndScale()`、`getScale()`、`getFinished()`、`shouldDestroy(realtime)` 查询。
|
||||
- `unlinkNode()`、`updateTransforms()`、`updateTransformsAll()`、`walkBreadthFirstIds()`、`walkDepthFirstIds()`
|
||||
|
||||
辅助对象:
|
||||
@@ -198,6 +200,22 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
|
||||
- `eve.SceneEntity`:`scene()` / `node()` / `onAttach()` / `onDetach()` / `update(dt)`,组件槽字段与 `eve.Entity` 一致。
|
||||
- `eve.SceneNodeRef`(节点句柄):`getNodeId()`、`getPersistentId()`、`getHostName()`、`isValid()`、`getScene()`、`setPosition()` / `getPosition()`、`setRotation()` / `getRotation()`、`setScale()` / `getScale()`、`setVisible()` / `isVisible()`、`getWorldPosition*()`、`getWorldMatrix()`、`getForward()/getRight()/getUp()`、`getParentId()`、`getChildCount()`、`getChildIdAt()`、`getPath()`、`attachEntity()`、`detachEntity()`、`getEntity()`、`hasEntity()`、`entitiesOf()`、`linkRenderable2D/3D()`、`linkPhysics2D/3D()`、`linkCamera3D()`、`linkAudio3D()`、`unlinkNode()`、`unlinkNodeKind()`、`linkCount()`、`localToWorld()`、`worldToLocal()`、`setParent()`、`removeNode()`、`setQuaternion()`、`getQuaternion()`、`lookAt()`、`addTag()`、`removeTag()`、`hasTag()`、`getTags()`、`setLayer()`、`getLayer()`、`setBounds()`、`hasBounds()`、`getBounds()`。
|
||||
|
||||
## 跟随玩家的环境对象
|
||||
|
||||
`FollowPlayerSettings`、`FollowPlayerInput`、`FollowPlayerOutput` 与
|
||||
`evaluateFollowPlayer(output,settings,input)` 对应 Pcg `FollowPlayerSystem`。在 LateUpdate 阶段填入玩家世界
|
||||
坐标,将 `applyPosition=true` 的输出位置应用到每个调用者拥有的粒子、水体或场景节点。关闭 offset 时完全
|
||||
复制玩家坐标;普通 offset 使用 `(playerX+offsetX, playerY-offsetY, playerZ-offsetZ)`;water 模式在玩家
|
||||
Y<1 时额外抬高 70,否则抬高 10,再减 offsetY。`useScale` 独立发布一次性 scale 写入语义。
|
||||
|
||||
计算层不查找全局 Camera、不持有对象列表,也不直接依赖 graphics、water 或 particles provider;调用者负责
|
||||
稳定对象身份和实际变换写入。缺少玩家或关闭 follow 时不发布位置,缩放仍可发布。所有输入先验证有限性,
|
||||
失败不会部分覆盖 output。
|
||||
|
||||
脚本字段:settings 提供 `followPlayer`、`waterObject`、`useOffset`、`offsetX/Y/Z`、`useScale`、
|
||||
`scaleX/scaleY/scaleZ`;input 提供 `hasPlayer`、`playerX/Y/Z`;output 提供 `applyPosition`、`x/y/z`、
|
||||
`applyScale` 与 `scaleX/scaleY/scaleZ`。
|
||||
|
||||
## 使用要点
|
||||
|
||||
- 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。
|
||||
@@ -206,3 +224,26 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
|
||||
|
||||
**源码:** [`src/modules/scene/`](../../../src/modules/scene/)
|
||||
**相关测试:** 在 [`test/`](../../../test/) 中搜索 `scene`。
|
||||
### Pcg 位置与书签
|
||||
|
||||
`LocationProfile` 保存一次性的启动位置,并管理 Pcg `LocationSystem` 风格的命名书签。
|
||||
`LocationPose` 以位置和四元数保存相机/玩家姿态;`LocationBookmark` 还保存控制器与场景名。
|
||||
书签暴露 `name`、`controller`、`scene` 字段,以及 `setCamera`、`setPlayer`、`clearPlayer`。
|
||||
脚本可调用 `saveLocation`、`saveLocationWithPlayer`、`loadLocation`、`addBookmark`、
|
||||
`overrideBookmark`、`removeBookmark`、`loadBookmark`、`previousBookmark`、`nextBookmark`、
|
||||
`clearBookmarks`、`getBookmarkCount`、`getBookmarkName`、`getBookmarkController`、
|
||||
`getBookmarkScene` 和 `hasSavedLocation`。修改与读取操作返回标准 `Result`;加载启动位置只成功一次。
|
||||
`serializeJson` 与 `restoreJson` 使用 `eve.scene.location-profile` schema version 1;恢复会拒绝未知字段,
|
||||
并在任何解析或验证失败时保留原状态。
|
||||
### PcgScenePlayer 地形视锥裁剪
|
||||
|
||||
`applyPcgTerrainCullingAt(host,camera,viewWidth,viewHeight,tag)` 检查指定 host 内带 tag 且具有 bounds 的
|
||||
节点,并把视锥相交结果原子应用到节点 visibility;返回实际变化数量。当前 host 的简写为
|
||||
`applyPcgTerrainCulling(camera,viewWidth,viewHeight,tag)`。未标记节点不受影响,场景加载器或 streaming
|
||||
层可在组成变化后重新调用,替代 Pcg 在 sceneLoaded/sceneUnloaded 后 0.5 秒重新扫描 Unity Terrain 的路径。
|
||||
|
||||
### PcgBuildConfig
|
||||
|
||||
`eve.PcgBuildConfig()` 对应 Pcg ResourcesSystem 的 `BuildConfig`。发布类型为 Addressables=0、RegularBuild=1;`addHistory(category,sceneName,timestamp)` 按调用顺序记录七类构建事件,category 为 0..6。使用 `getHistoryCount/Category/SceneName/Timestamp` 查询,越界或非法枚举返回结构化错误。
|
||||
|
||||
脚本方法:`setPublicationType()`、`getPublicationType()`、`addHistory()`、`clearHistory()`、`getHistoryCount()`、`getHistoryCategory()`、`getHistorySceneName()`、`getHistoryTimestamp()`。
|
||||
@@ -14,7 +14,7 @@ sys.setClipboardText("EVEngine");
|
||||
|
||||
## 对象关系与调用时机
|
||||
|
||||
`System` 是无状态的平台查询入口。硬件信息适合启动时缓存;wall time、进程内存和电量可低频刷新;`sleepMilliseconds()` 会阻塞当前线程。
|
||||
`System` 是平台查询和主循环帧率策略入口。硬件信息适合启动时缓存;wall time、进程内存和电量可低频刷新;`sleepMilliseconds()` 会阻塞当前线程。
|
||||
|
||||
## 目标导向指南
|
||||
|
||||
@@ -38,7 +38,12 @@ sys.setClipboardText("EVEngine");
|
||||
|
||||
- `getCPUCacheLineSize()`、`getClipboardText()`、`getEngineVersion()`、`getGpuDeviceType()`、`getGpuMemoryTotalMB()`、`getGpuName()`、`getGpuVendor()`、`getName()`
|
||||
- `getOS()`、`getPlatform()`、`getPowerPercent()`、`getPowerSecondsLeft()`、`getPowerState()`、`getProcessMemoryMB()`、`getProcessorCount()`、`getSystemRAM()`
|
||||
- `getWallTime()`、`setClipboardText()`、`sleepMilliseconds()`
|
||||
- `getWallTime()`、`setClipboardText()`、`sleepMilliseconds()`、`limitFrame()`
|
||||
- `getPhotoModeVSync()`、`getPhotoModeTargetFPS()`
|
||||
|
||||
Pcg `FrameRateManager` 由 `eve.PcgFrameRateManager()` 承接。`configure(targetFrameRate,checkInterval,minQuality,maxQuality,currentQuality)` 原子配置状态机;每帧调用 `update(dt,timeScale)`,再读取 `getFps()`、`getQuality()`、`getQualityChanged()` 和 `getPreset()`。手动选择使用 `selectManualQuality(level)`,自动模式使用 `setAutomatic(enabled)` / `getAutomatic()`。返回的 `PcgTerrainQualityPreset` 提供 `treeDistance`、`treeBillboardDistance`、`treeCrossFadeLength`、`treeMaximumFullLodCount`、`detailObjectDistance`、`detailObjectDensity`、`heightmapPixelError`、`heightmapMaximumLod`、`basemapDistance`。
|
||||
|
||||
PhotoMode 的 System 域由模块实例自动接管。`m_vSync` 的 0/1/2 分别保留“不同步/每次垂直空白/每两次垂直空白”语义,并通过 Graphics presentation capability 触发真实交换链策略更新;非零 VSync 时忽略软件帧率上限。`m_targetFPS` 支持 -1 到 240,VSync 为 0 且值大于 0 时,桌面主循环在 present 后调用 `limitFrame()` 节流。由浏览器宿主驱动帧循环时不会阻塞宿主调度。
|
||||
|
||||
## 使用要点
|
||||
|
||||
@@ -48,3 +53,10 @@ sys.setClipboardText("EVEngine");
|
||||
|
||||
**源码:** [`src/modules/system/`](../../../src/modules/system/)
|
||||
**相关测试:** 在 [`test/`](../../../test/) 中搜索 `system`。
|
||||
## Pcg 延迟任务队列
|
||||
|
||||
`eve.PcgTaskQueue()` 移植 Pcg `PcgTask` / `PcgTaskBase` 的等待与轮询生命周期。`add(waitSeconds)`
|
||||
加入任务并返回稳定 ID,默认等待 0.25 秒;`tick(deltaSeconds)` 只把到期任务发布为 Ready,调用方通过
|
||||
`getReadyTaskId` 取得 ID、在队列外执行任务,然后调用 `resolveReady(finished)`。未完成任务在下一轮继续,
|
||||
完成任务被移除。`cancelAll` 清空队列;`getQueueSize` 与 `getStatus` 提供状态。队列不保存回调或脚本对象,
|
||||
显式 dt 使调度可回放,非法时间不会改变队列。
|
||||
@@ -328,6 +328,18 @@ ui.setHostWorldOverlap(true, 10, 4.0, 96.0)
|
||||
`editors/*.vm.nut` 或 `editors/*.editor.json`;`hotReloadStatus()` 返回 watcher、
|
||||
成功/失败计数与最近诊断。正常保存会由 MCP 主机自动触发,无需重启。
|
||||
|
||||
## Canvas 高度驱动的父布局
|
||||
|
||||
`ParentScalerSettings`、`ParentScalerInput`、`ParentScalerState`、`ParentScalerOutput` 和
|
||||
`evaluateParentScaler(state,output,settings,input)` 对应 Pcg `ParentScaler`。`scaleWithCanvas=true` 且存在
|
||||
Canvas 和目标时,FullScreen 输出原始 `canvasHeight`;`partScreen=true` 时按 Pcg 公式将其钳制到
|
||||
`[0.1,maxHeight]`。只有 Canvas 高度变化或 `lastScaleHeight==0` 时才令 `applyHeight=true`,调用者再把
|
||||
`height` 应用到每个仍有效的目标 Rect,保留各自宽度。
|
||||
|
||||
脚本字段包括 `scaleWithCanvas`、`partScreen`、`maxHeight`、`hasCanvas`、`targetCount`、`canvasHeight`、
|
||||
`lastScaleHeight`、`applyHeight` 和 `height`。状态与输出由调用者持有;零目标不会消耗高度变化,后续加入目标
|
||||
仍能获得写入。无效目标由调用者跳过,负目标数或非有限高度在修改 state/output 前失败。
|
||||
|
||||
## 使用要点
|
||||
|
||||
- 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。
|
||||
@@ -336,3 +348,98 @@ ui.setHostWorldOverlap(true, 10, 4.0, 96.0)
|
||||
|
||||
**源码:** [`src/modules/ui/`](../../../src/modules/ui/)
|
||||
**相关测试:** 在 [`test/`](../../../test/) 中搜索 `ui`。
|
||||
|
||||
PhotoMode 的 Photo 域由 `PcgPhotoModePhotoAuthority` 保存并统一发布。它覆盖场景身份、
|
||||
截图分辨率、EXR/JPG/PNG/TGA 格式、加载保存设置、关闭时恢复,以及 FPS、准星和三分法
|
||||
叠层开关。分辨率索引 0 使用当前 drawable 尺寸,1..9 精确映射 Pcg 的 640×480 到
|
||||
7680×4320 档位;`screenshotSize()` 与 `screenshotExtension()` 供捕获和 UI 调用方读取。
|
||||
authority 只保存稳定值,不持有 widget;实际叠层仍由 UIHost 使用现有 UI API 构建。
|
||||
|
||||
### Pcg 截图按钮与颜色预览同步
|
||||
|
||||
`AutoAssignTakePhotoEvent` 的启动期按钮接线使用现有 `ui.onClick(id, callback)`:回调中调用 `gfx.saveFramePng(path)`;首次启用 readback 后若尚无已呈现帧会返回 `false`,下一帧再次调用。该组合对应 Unity `Button.onClick -> ScreenShotter.TakeHiResShot`,并允许调用者显式选择输出路径。
|
||||
|
||||
`eve.PcgColorPreviewSync()` 对应 `ColorPreviewSync.OnEnable`。调用 `sync(r,g,b,a)` 后,通过 `getRed()`、`getGreen()`、`getBlue()`、`getAlpha()` 取得按钮 highlighted color:纯黑源色映射到 `(0.5,0.5,0.5,1)`,其余颜色严格乘以原脚本硬编码的 `2.5`。非有限输入返回结构化错误且保留原状态。
|
||||
|
||||
### PcgDraggableWindow
|
||||
|
||||
`eve.PcgDraggableWindow()` 对应照片模式的 `DraggableUIWindow`。`configure(x,y,width,height,screenWidth,screenHeight,canvasScale)` 保存初始 anchored position;`drag(deltaX,deltaY)` 按 canvas scale 换算并复现 Pcg 的 X 负向、Y 正向边界钳制。`pointerDown(middleButtonDown)` 总是产生置顶请求,中键同时复位;用 `consumeBringToFront()` 消费请求,并通过 `getX()` / `getY()` 将结果交给 `ui.setHostPos`。
|
||||
|
||||
### PcgPhotoModePanels
|
||||
|
||||
`eve.PcgPhotoModePanels()` 保留七个 Pcg 照片模式页签的固定编号:Camera=0、Unity=1、Terrain=2、Lighting=3、Water=4、PostFX=5、PhotoMode=6。`select(panel)` 原子关闭其余六个面板并选择目标;`getSelected()`、`isActive(panel)`、`getRevision()` 提供快照,`consumeScrollReset()` 通知调用者重建 scroll content 并把滚动条置顶。
|
||||
|
||||
`eve.PcgPhotoModePanelButton()` 通过 `configure(nonSelectedRGBA,selectedRGBA)` 保存两组颜色,`setSelected()` 同时控制按钮 normal/selected 色和面板布局开关。读取接口为 `getNormalR()`、`getNormalG()`、`getNormalB()`、`getNormalA()`、`getHighlightR()`、`getLayoutEnabled()`、`getSelected()`;highlight 始终来自选中色,与 Pcg 注册逻辑一致。
|
||||
|
||||
### PcgPhotoModeColorPicker
|
||||
|
||||
`eve.PcgPhotoModeColorPicker()` 实现照片模式颜色选择器。`open(r,g,b,a,hdr)`、`setLast(...)`、`setFocusedName(name)`、`close()` 管理打开、旧值和标题;`setRed()`、`setGreen()`、`setBlue()`、`setHdr()` 对应四条滑杆,`reset()` 恢复旧值。读取接口包括 `getRed/Green/Blue/Alpha`、`getPreviewRed/Green/Blue`、`getHdrSwatch()`、`getHdrEnabled()`、`getOpen()`、`getFocusedName()`、`getChangeRevision()` 和 `consumeBringToFront()`。HDR alpha 大于 1 或小于 0 时严格保留 Pcg 打开阶段和预览阶段的两次 RGB 乘法。
|
||||
预览通道的完整方法名为 `getPreviewRed()`、`getPreviewGreen()`、`getPreviewBlue()`。
|
||||
|
||||
### PcgScreenshotSavedNotice
|
||||
|
||||
`eve.PcgScreenshotSavedNotice()` 对应截图保存提示。`configure(enabled,showSeconds)` 设置开关和持续时间;截图成功后调用 `request(path)`,在该帧渲染结束调用 `endFrame(unscaledNow)` 才显示,再用 `tick(unscaledNow)` 到期隐藏。`getVisible()`、`getPathVisible()`、`getPath()`、`getPending()` 返回 UI 快照。重复 request 会取消旧显示并从新的 end-of-frame 重新计时。
|
||||
|
||||
### PcgTooltipManager
|
||||
|
||||
`eve.PcgTooltipManager()` 合并移植 Tooltip、TooltipManager、TooltipProfile 和 TooltipTrigger。`configure(interactionMode,theme,delay,bottomOffset,topOffset,wrapLimit)` 中 interactionMode 为 UI=0、SceneObjects=1、Both=2,theme 为 Light=0、Dark=1。`addTooltip()`、`removeTooltip()` 管理 profile;`enter(source,id,now)` 做 Pcg 的首个 substring 匹配,`enterText(source,content,header,now)` 对应 Trigger 自带文本,`exit(source)`、`hide()` 和 `tick(now,mouseX,mouseY,screenWidth,screenHeight,anyInput)` 管理延迟、位置与隐藏。
|
||||
|
||||
状态通过 `getVisible()`、`getPending()`、`getHeader()`、`getText()`、`getHeaderVisible()`、`getWrapEnabled()`、`getPivotX()`、`getPivotY()`、`getBackgroundR()`、`getForeground()`、`getCount()` 读取。
|
||||
|
||||
### PcgControllerSelection
|
||||
|
||||
`eve.PcgControllerSelection()` 对应 `UIControllerSelection`。用 `add(name,controllerType,widgetId)` 按 Inspector 顺序登记说明项,`refresh(currentController)` 选择第一个类型匹配项并生成互斥可见快照;没有匹配时返回成功但 value=false,保留之前状态。通过 `getCount()`、`getSelectedIndex()`、`getVisible(index)`、`getWidgetId(index)` 读取,再用 `ui.setVisible` 应用到稳定 widget id。
|
||||
|
||||
### PcgPhotoModeRuntimeUI
|
||||
|
||||
`eve.PcgPhotoModeRuntimeUI()` 对应 `PhotoModeUtils` 与 `PhotoModeUIHelper` 的运行时控件状态。`configure(kind,name,value,current,min,max,imageFound)` 的 kind 从 Field=0 到 Vector3=13。各 `get*Visible()` 返回原 prefab 的互斥可见快照,`getInitialCallbackRevision()` 记录配置后主动派发初值的次数。
|
||||
|
||||
### PcgLoadingScreen
|
||||
|
||||
`eve.PcgLoadingScreen()` 接收 TerrainLoader 的四类加载事件。`configure(fadeOutSpeed)` 设置背景每秒
|
||||
衰减的 alpha;`onLoadProgressStarted()` 显示画布并把背景和进度复位,
|
||||
`onLoadProgressUpdated(progress)` 更新进度且在值达到 1 时自动执行 `onLoadProgressEnded()`。
|
||||
结束会隐藏进度条和文本并开始淡出,调用方每帧用 `tick(deltaTime)` 推进;alpha 到零后画布关闭。
|
||||
|
||||
超时诊断使用 `beginTimeout()` 开始事务,`addMissingScene(terrainName,impostorName)` 返回索引,随后用
|
||||
`addRegularReference(index,name)` 和 `addImpostorReference(index,name)` 收集仍引用该场景的对象,最后
|
||||
`endTimeout()` 原子发布与 Pcg 相同结构的诊断并开始淡出。`getCanvasVisible`、`getProgressVisible`、
|
||||
`getTextVisible`、`getFading`、`getProgress`、`getBackgroundAlpha` 和 `getTimeoutMessage` 返回 UI 快照。
|
||||
|
||||
`markSliderUsed()`、`setSliderValue()`、`applyFloatInput()` 保留滑杆与文本框同步规则;`updateWrap(text)` 复现 68 字符 metrics 换行,包括无空格硬切时跳过切点字符的原始行为。
|
||||
|
||||
状态查询包括 `getKind()`、`getName()`、`getValueText()`、`getValue()`、`getMinimum()`、`getMaximum()`、
|
||||
`getWholeNumbers()`、`getUsingSlider()`、`getInputRefresh()`、`getLabelVisible()`、
|
||||
`getSecondLabelVisible()`、`getSliderVisible()`、`getInputVisible()`、`getToggleVisible()`、
|
||||
`getButtonVisible()`、`getDropdownVisible()`、`getImageVisible()`、`getHeaderVisible()`、`getColorVisible()`、
|
||||
`getVector2Visible()`、`getVector3Visible()` 和 `getInitialCallbackRevision()`。
|
||||
|
||||
### PcgPhotoModeValues
|
||||
|
||||
`eve.PcgPhotoModeValues()` 完整保存 Pcg `PhotoModeValues` 的 102 个字段及原始默认值。字段名沿用 `m_*` 配置名;`getFieldCount/getFieldName/getFieldType` 提供稳定 schema,类型编号为 Bool=0、Int=1、Float=2、String=3、Color=4。使用对应 `setBool/setInt/setFloat/setString/setColor` 与 `getBool/getInt/getFloat/getString`;颜色先 `selectColor(name)`,再读取 RGBA。`resetDefaults()` 原子恢复原始配置。
|
||||
|
||||
`snapshotJson()` 输出 `eve.ui.pcg-photo-mode-values` schema version 1,并包含全部 102 字段;`restoreJson(json)` 要求精确 schema、版本和字段集合,拒绝未知或缺失字段,解析到临时候选对象后一次性交换,因此任何错误都保留当前配置。
|
||||
|
||||
### PcgPhotoModeSession
|
||||
|
||||
`eve.PcgPhotoModeSession()` 对应 `PhotoMode.cs` 的进入、加载和退出事务。`begin(capturedJson,savedJson,loadSaved,savedEver,currentPipeline,savedPipeline,sceneName,lightingProfile)` 保存进入前快照,仅在管线、场景和光照配置均匹配时加载保存值。`getLoadDecision()` 返回 Current=0、Saved=1、SaveCurrent=2。`replaceWorkingJson()` 原子更新运行值;`end(resetOnDisable,applicationPlaying)` 选择工作值或进入前快照作为 `outputJson()`。
|
||||
|
||||
退出后 `getRemovePhotoCameraRequested()` 和 `getUnfreezePlayerRequested()` 始终为真;只有两个退出参数均为真时 `getRestoreRequested()` 为真。调用者按这些请求恢复各权威模块并清理临时对象。
|
||||
|
||||
脚本方法:`begin()`、`replaceWorkingJson()`、`end()`、`workingJson()`、`outputJson()`、`getActive()`、`getRestoreRequested()`、`getRemovePhotoCameraRequested()`、`getUnfreezePlayerRequested()`、`getLoadDecision()`、`getRevision()`。
|
||||
|
||||
### PcgPhotoModeRanges
|
||||
|
||||
`eve.PcgPhotoModeRanges()` 完整保存 `PhotoModeMinAndMaxValues` 的 57 组范围。`getCount/getName` 按原声明顺序枚举;`select(name)` 后用 `getMinimum/getMaximum/getIntegral` 读取。`setRange()` 原子替换自定义范围,整数范围要求整数端点;`clamp(name,value)` 返回钳制结果。`m_densityVolumeFogDistance` 的默认上限保留正无穷。
|
||||
|
||||
### Pcg PhotoMode 跨域应用事务
|
||||
|
||||
PcgPhotoModeApplyPlan 比较两个完整 PcgPhotoModeValues 快照,按 Pcg 源字段声明顺序输出变更,并把字段路由到 Photo、System、Graphics、Camera、Streaming、Weather、Lighting、Water、PostFx、Terrain、Grass 或 Audio 权威域。executeRegistered() 通过 eve.photo-mode.apply capability 同步执行;未安装提供者会返回可观察的 Unsupported 错误。任一字段失败时,先前字段按逆序恢复,getRollbackComplete() 报告恢复是否完整。脚本可用 compileJson、命令枚举和执行状态来驱动照片模式。
|
||||
|
||||
脚本方法:compileJson、executeRegistered、getCommandCount、getCommandField、getCommandDomain、getAppliedCount、getRollbackComplete、getRevision。
|
||||
|
||||
照片模式注册执行器会把 m_globalVolume 通过现有 IAudioQuery 写入真实 Audio master volume。其他字段由实现 IPhotoModeFieldSink 的模块 listener 接收;每个字段必须恰有一个提供者,缺失或重复所有者都会使事务失败并触发逆序回滚。
|
||||
|
||||
### Pcg Lighting profile 身份
|
||||
|
||||
PhotoMode 的 `m_isUsingPcgLighting` 与 `m_selectedPcgLightingProfile` 由 `PcgPhotoModePhotoAuthority` 作为持久化身份联合保存。`lightingProfileMatches(currentProfile)` 同时比较 profile 索引和 Pcg/custom 模式;`PcgPhotoModeSession.begin()` 也使用这两个条件与场景名、渲染管线共同决定是否允许加载旧设置。
|
||||
@@ -77,7 +77,7 @@ gfx.render3D();
|
||||
强度额外压暗;调用方可以通过天空颜色、太阳强度控制阴天氛围。
|
||||
- `examples/weather` 在 `render3D` 后使用 GBuffer 深度和 `Volumetric.applyFog` 叠加
|
||||
场景雾,再绘制 UI。GBuffer 使用片段位置深度,避免三角形透视插值造成的雾量接缝。Weather 本身只对天气粒子应用雾,不自动拥有场景后处理。
|
||||
- 雪花不模拟屋顶碰撞、地面积雪或植被受风;这些需要对应场景系统提供。
|
||||
- 雪花的屋顶碰撞需要粒子/物理提供者;地面积雪与植被受风需要对应场景系统提供。
|
||||
|
||||
模拟状态仍由 Weather 实例持有,更新在渲染主线程、`render3D` 前执行;没有新增公共
|
||||
对象所有权、跨域 Link 或持久格式。运动使用调用方提供的 dt,初始化粒子使用固定 seed。
|
||||
@@ -85,3 +85,63 @@ GLSL/WGSL 采用相同公式,但浮点三角函数及覆盖率采样不保证
|
||||
|
||||
修改 GLSL 后运行 `python scripts/compile_weather_shaders.py` 更新嵌入式 SPIR-V。
|
||||
WGSL 对应源码位于 `src/modules/weather/shaders/WeatherWgsl.h`,需同步维护并运行后端验证。
|
||||
### Pcg 雷击状态
|
||||
|
||||
`ThunderStrikeSettings` 提供 `intensity`、`radius`、`volume` 和 `audioClipCount`;
|
||||
`ThunderStrikeState` 提供 `playing` 与当前 `intensity`。`triggerThunderStrike` 接收玩家世界坐标和显式
|
||||
seed,返回雷击 `x/y/z`、光强、半径、音量和 `audioClipIndex`。与 Pcg 源码一致,存在至少两个片段时
|
||||
索引从 1 开始,片段 0 不参与随机选择。`advanceThunderStrike` 实现 clamped `Lerp(intensity,0,dt*2)`
|
||||
以及低于 0.15 时停止的规则;两个函数均返回标准 `Result`。
|
||||
|
||||
### Pcg 三轴雪风
|
||||
|
||||
`setSnowWind(x,y,z)` 直接设置 `PW_VFX_Snow_Controller.SnowWindDir` 对应的世界空间雪粒子速度;
|
||||
Y 为有符号速度,负值下落、零值悬停、正值上升。`clearSnowWind()` 恢复由
|
||||
`setWindSpeed/setWindDirection` 计算的水平风和默认下落速度。`hasSnowWind()`、`getSnowWindX()`、
|
||||
`getSnowWindY()`、`getSnowWindZ()` 提供可观察状态。非有限输入返回标准 Result 且保持旧值。
|
||||
|
||||
### Pcg 室内天气体积
|
||||
|
||||
`PcgInteriorWeatherVolume` 复现 Pcg 的 Box/Sphere 室内判定以及 `Collision`、
|
||||
`DisableVFX` 两种策略。`configureBox` / `configureSphere` 原子校验形状、模式和
|
||||
内外混响 preset id;`weather.applyInteriorVolume(volume, x, y, z)` 用于每帧 Bounds
|
||||
采样,`weather.setInteriorVolumeState(volume, inside)` 用于物理 Trigger 回调。返回值的
|
||||
`value` 为 0(无变化)、1(进入)或 2(离开)。Weather 只复制状态,不保存 volume 指针。
|
||||
|
||||
进入 `DisableVFX` 体积会隐藏雨雪;进入 `Collision` 体积会隐藏 Pcg 原实现中的 rain
|
||||
mesh,并通过 `isInteriorWeatherCollisionRequested()` 暴露碰撞请求,供可选粒子/物理适配器
|
||||
消费。核心 weather 模块不直接依赖 audio 或 particles;当前混响 preset 可由
|
||||
`getCurrentWeatherReverbPreset()` 读取并投影到音频后端。
|
||||
|
||||
补充状态接口:`contains(x, y, z)` 可独立检查 volume;
|
||||
`isInsideInteriorWeather()` 返回当前室内状态;`clearInteriorWeather()` 清除该状态并恢复外部
|
||||
混响 preset。
|
||||
|
||||
### Pcg PhotoMode Weather 适配器
|
||||
|
||||
Weather 模块会在自身生命周期内注册照片模式字段提供者。m_pcgWeatherEnabled、m_pcgWeatherRain、m_pcgWeatherSnow、m_pcgWindSettingsOverride、m_pcgWindDirection 和 m_pcgWindSpeed 直接写入 Weather 权威状态;Rain 与 Snow 可同时启用,Weather Enabled 统一控制显示。setPreset 会退出照片模式的天气接管并恢复普通 preset 行为。
|
||||
|
||||
### Pcg PhotoMode 时间适配器
|
||||
|
||||
`PcgLightingTimePhotoMode` 将 Lighting 域的时间设置接入现有 `DayNight` 时钟。显式设置
|
||||
借用目标并取得 authority 后,`m_pcgTime` 写入 0..24 小时,`m_pcgTimeScale` 写入
|
||||
0..200 推进倍率,`m_pcgTimeOfDayEnabled` 直接控制 paused 状态。目标必须比适配器
|
||||
活得更久;换场景时先撤销 authority 或销毁适配器。
|
||||
### Pcg PhotoMode 手动太阳适配器
|
||||
|
||||
`PcgLightingSunPhotoMode` 负责 `m_sunRotation`、`m_sunPitch`、`m_sunOverride`、
|
||||
`m_sunIntensity`、`m_sunColor` 和 `m_sunKelvinValue`。六项值作为联合状态原子写入
|
||||
`DayNight`;override 开启后,太阳方向、直射光、天空盒太阳盘、雾和反射探针共享同一
|
||||
持久结果,时钟继续运行也不会覆盖它。关闭 override 会在下一次更新恢复程序化太阳。
|
||||
|
||||
### Pcg PhotoMode 天空盒适配器
|
||||
|
||||
`PcgLightingSkyboxPhotoMode` 接管 `m_skyboxOverride`、`m_skyboxRotation`、`m_skyboxExposure` 和 `m_skyboxTint`。覆盖开启时,旋转、曝光与 RGB tint 持久进入 DayNight 的程序化天空、实际天空 cubemap、背景色与反射探针;关闭覆盖会恢复 DayNight 原有天空参数。
|
||||
|
||||
### Pcg PhotoMode 雾与 Density Volume 适配器
|
||||
|
||||
`PcgLightingFogPhotoMode` 联合接管普通雾、Pcg Weather 附加雾、全局密度倍率以及 HDRP Density Volume 的 14 个字段。`DayNight.applyAtmosphere()` 每帧将权威状态投影到真实 `Volumetric`;Density Volume 开启时优先使用其 albedo、可视距离、五档 haze 和六档质量。覆盖首次启用会保存目标原有 mode、quality、density 与距离,全部覆盖关闭后原子恢复。
|
||||
|
||||
### Pcg PhotoMode 环境光适配器
|
||||
|
||||
`PcgLightingAmbientPhotoMode` 接管环境强度、天空/地平线/地面三色与全局直射光倍率。三色按半球梯度合成进入 DayNight ambient,倍率同步作用于方向光、雾散射与其他 `getSunRGB` 消费者。authority 启用时保存原状态,撤销或析构时恢复。
|
||||
@@ -9,6 +9,36 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
部分示例另有 Makefile 快捷入口:`make devlab` / `basic` / `ecs` / `rpg` / `inventory` / `building`。
|
||||
新手建议顺序:`devlab` → `basic` → 你感兴趣的玩法示例 → 程序化生成示例。
|
||||
|
||||
## 示例规范(新增示例必读)
|
||||
|
||||
`python3 scripts/check_examples.py` 在 CI 的 `source-quality` 阶段强制目录契约,
|
||||
`scripts/smoke_examples.sh` 在 Linux 阶段强制运行时契约:
|
||||
|
||||
1. **目录形式**:`examples/<name>/` 要么是可运行示例(`main.nut` + `config.nut`),要么是在
|
||||
`scripts/check_examples.py` 的 `NON_RUNNABLE_EXAMPLES` 中登记了原因的非运行样例
|
||||
(C++ 程序 / 插件 / 资产包)。新增目录两者都不满足时 CI 直接失败。
|
||||
2. **`config.nut` 必须赋值 `config` 表**:引擎先给默认 `config`,再 `dofile("config.nut")`。
|
||||
自造的 `window_title` / `window_width` 之类全局不会被读取,窗口会静默退回默认 800×600。
|
||||
3. **每个示例目录都要有 `README.md`**:说明演示内容、运行命令、操作方式与相关文件。
|
||||
4. **每个示例都要登记在本文件的表格中**:不允许出现指向不存在目录的死链接。
|
||||
5. **至少运行 2 秒不报错**:`scripts/smoke_examples.sh` 逐个启动可运行示例,`MIN_RUN_SECONDS`
|
||||
(默认 2 秒,CI 同为 2 秒;每个示例实际观察 `RUN_SECONDS`,默认 6 秒)之后进程必须
|
||||
仍然存活,且日志中不得出现 `frame error`、
|
||||
`Run failed:`、`eve_init failed`、`failed to load:` 等错误标记;提前退出或非零退出码都算失败。
|
||||
新示例因此必须真的能启动并进入帧循环,不能靠“跳过”过 CI。
|
||||
|
||||
本地自查:
|
||||
|
||||
```bash
|
||||
python3 scripts/check_examples.py # 目录 / 文档 / 登记契约
|
||||
python3 scripts/check_examples.py --print-missing-rows # 打印缺失的登记行
|
||||
MIN_RUN_SECONDS=2 RUN_SECONDS=6 bash scripts/smoke_examples.sh <name>
|
||||
```
|
||||
|
||||
Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/stderr,`--print-missing-rows`
|
||||
打印的中文标题不会因为控制台代码页是 GBK 而报错(约定见 `AGENTS.md` 与
|
||||
`scripts/tests/test_utf8_stdio.py`)。
|
||||
|
||||
## 开发者体验
|
||||
|
||||
| 示例 | 演示能力 |
|
||||
@@ -16,8 +46,8 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
| [devlab](devlab/README.md) | 运行时即编辑器:`--debug` 下的控制台 / REPL、快照、暂停、热重载、错误切片、AI 面板(**旗舰示例,先跑这个**) |
|
||||
| [ai-game](ai-game/README.md) | AI 驾驶真实游戏:Agent 通过 MCP 读状态 / 改数值 / 截图 / 快照复位(含一键复现脚本 `agent_demo.py`) |
|
||||
| [ai-editor](ai-editor/README.md) | AI 现场生成项目专属编辑器:`eve mcp` 无头主机 + JSON View + ViewModel 双向绑定(含 `editor_demo.py`) |
|
||||
|
||||
新增人物动画示例:[Character Motion Lab](character-motion-lab/README.md) — 三个人物、八种免费动作、跨骨架重定向、同步播放与逐帧观察。
|
||||
| [inspector-demo](inspector-demo/README.md) | DevTools 属性检视器:自动扫描脚本类及其属性并渲染为可编辑面板(MVVM) |
|
||||
| [editor-ui-gallery](editor-ui-gallery/README.md) | 可复用编辑器 UI 套件展示:图标字体、菜单栏、工具栏、搜索侧栏、分屏、卡片、表单控件与游戏内 HUD 共存 |
|
||||
|
||||
## 入门与基础
|
||||
|
||||
@@ -26,40 +56,64 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
| [basic](basic/README.md) | Tilemap、Box2D 物理、粒子、基础 2D 绘制;另含两个 UI 变体(`ui_demo.nut` / `ui_component.nut`) |
|
||||
| [ecs](ecs/README.md) | 脚本 ECS(Component / Entity / System);`gpu_main.nut` 为 compute shader 变体 |
|
||||
| [scene-ecs](scene-ecs/README.md) | 场景树与脚本 ECS 打通:SceneEntity 行为、eve.view 批量查询 |
|
||||
| [primitive-drawing](primitive-drawing/README.md) | 长期 `Primitive3D` 脚本 API:2D / 3D 基础图形绘制 |
|
||||
|
||||
## 2D 玩法系统
|
||||
|
||||
| 示例 | 演示能力 |
|
||||
|---|---|
|
||||
| [rpg](rpg/README.md) | RPG 五系统:属性 / 效果 / 状态 / 技能 / 结算(最小可玩动作 RPG) |
|
||||
| [rpg-classic](rpg-classic/README.md) | 经典回合制 RPG 纵向切片:`eve.RPG()` + `eve.Inventory()` + `map` 的数据驱动界面 |
|
||||
| [inventory](inventory/README.md) | 背包:物品定义、主背包 / 任务栏 / 仓库、装备穿脱、跨包转移 |
|
||||
| [cardgame](cardgame/README.md) | 可玩的双人德州扑克:四轮下注、AI、七选五牌型、真实烧牌与 52 张 CC0 牌面 |
|
||||
| [dialogue](dialogue/README.md) | 对话 + Avatar:Squirrel generator 剧情、i18n 翻译表、分层立绘、程序化台词池(.dnut) |
|
||||
| [galgame](galgame/README.md) | 完整视觉小说《潮汐电台》:原创美术、分支结局、AUTO/SKIP/回想与存读档 |
|
||||
| [building](building/README.md) | 建筑放置:地形约束、道路邻接、鬼影预览、旋转拆除 |
|
||||
| [building-tilemap](building-tilemap/README.md) | 在等距 / 六角 tilemap 上放置建筑(2D 精灵穿插) |
|
||||
| [autotile-production](autotile-production/README.md) | 生产级 autotile:岸线 / 墙体 / 瀑布三层规则与动画帧(零美术资源) |
|
||||
| [swappable-tilesets](swappable-tilesets/README.md) | RPG Maker 风格图集接入:可替换 tileset + 自动拼接 |
|
||||
| [pixelworld](pixelworld/README.md) | Noita 风格的分块沙、水、油、火焰与蒸汽像素物质仿真 |
|
||||
| [iso-grid-walk](iso-grid-walk/README.md) | 独立 2.5D PNG 经可插拔 pipeline 生成 TileSet,方格移动与 A* |
|
||||
| [hd2d-riverside](hd2d-riverside/README.md) | HD2D 河畔验收:真实像素素材、正交地图与 16 种地面图块 |
|
||||
| [dynamic-water-grid](dynamic-water-grid/README.md) | 双网格岸线、逐格守恒水量、等距连续水面 Shader 与素材替换契约 |
|
||||
| [metroidvania](metroidvania/README.md) | 物理驱动的横版动作游戏:连击、蹬墙跳、空中冲刺、Boss |
|
||||
| [commandery-rts](commandery-rts/README.md) | 将领行政 RTS:框选/编队移动、占领经济点、生产、军饷与叛乱 |
|
||||
| [rts-sandbox](rts-sandbox/README.md) | RTS 端到端 Squirrel 组合剖面:命令、生产、编队与结算 |
|
||||
| [hex-levels](hex-levels/README.md) | 六边形引擎功能测试关卡:寻路 / FOV / 光照 / 掉落 / WFC(31 关) |
|
||||
| [map-fog](map-fog/README.md) | 大地图迷雾:`MapFog` 双层云 + mask(解锁 / 选中 / 溶解) |
|
||||
| [i18n](i18n/README.md) | 本地化:翻译表、占位符、复数规则、热重载 |
|
||||
| [sprite-animation-vfx](sprite-animation-vfx/README.md) | 2D 精灵动画与 VFX API 的端到端脚本验证 |
|
||||
| [vehicle](vehicle/README.md) | 通用载具系统:`eve.Vehicle()` + `eve.Weapon()` 的 2D 顶视完整链路 |
|
||||
| [crowd](crowd/README.md) | 群体行为:2000 个单位在带障碍流场中行军与平滑转向(Boids) |
|
||||
| [composable-rebellion](composable-rebellion/README.md) | 可组合玩法:引擎只存事实、叛乱语义全在脚本(15 个 `eve.*` 模块协作) |
|
||||
|
||||
## 3D 玩法与镜头
|
||||
|
||||
| 示例 | 演示能力 |
|
||||
|---|---|
|
||||
| [camera-controllers](camera-controllers/README.md) | 第三人称相机:follow / orbit / topdown / firstperson / cinematic |
|
||||
| [character-motion-lab](character-motion-lab/README.md) | 三个人物、八种免费动作、跨骨架重定向、同步播放与逐帧观察 |
|
||||
| [layered-animation](layered-animation/README.md) | 真实 CC0 KayKit 动作叠加:三段动作单独播放 + `AnimLayerMixer`(走路基底 + 脊柱遮罩攻击覆盖 + 受击叠加) |
|
||||
| [climbing-motion-matching](climbing-motion-matching/README.md) | 攀爬与动作匹配演示(从示例目录内直接运行) |
|
||||
| [building-3d](building-3d/README.md) | 3D 地面放置:射线求交、网格吸附、鬼影与放置会话 |
|
||||
| [climbing-playground](climbing-playground/README.md) | 攀爬与跑酷:真实物理探测、低/高跨越、翻越、空中抓边、阻挡与移动平台 |
|
||||
| [daynight](daynight/README.md) | 昼夜循环:太阳轨道、程序化天空盒、月光 / 星光 / 火焰 / 萤火虫 |
|
||||
| [weather](weather/README.md) | 天气系统:雨 / 雪 / 雷暴 / 雾 / 风,实时滑块 |
|
||||
| [weather-daynight](weather-daynight/README.md) | 统一昼夜 + 天气:太阳轨道、程序化天空与雨雪雷暴 |
|
||||
| [snow](snow/README.md) | 可交互积雪:与高度图同尺寸的 `SnowField` 深度场驱动形变与着色 |
|
||||
| [softbody](softbody/README.md) | 布料与 2D 流体解算器(拖拽 / 排斥 / 吸引) |
|
||||
| [softbody3d](softbody3d/README.md) | 3D 软体:Verlet 布料 + 体积体与静态 Box3D 碰撞 |
|
||||
| [lattice-deform](lattice-deform/README.md) | 3D 晶格缩放变形:squash & stretch、局部鼓起、波浪 |
|
||||
| [sprite-stack](sprite-stack/README.md) | 伪 3D:把 3D 模型切成多层 RGBA 叠片渲染 |
|
||||
| [armored-command-3d](armored-command-3d/README.md) | 3D RTS 展示:骨骼坦克、编队命令、齐射与指挥所坍塌 |
|
||||
| [tactics](tactics/README.md) | 固定镜头 3D 战棋:4 名冒险者对战 6 种骷髅敌人(12×5 方格) |
|
||||
| [venom-transform](venom-transform/README.md) | 毒液变装效果近似:形变与材质过渡 |
|
||||
| [anime-character-lab](anime-character-lab/README.md) | anime 风格化着色检查:`stylize.newMeshShader(gfx, "anime")` |
|
||||
| [avatar-document-editor](avatar-document-editor/README.md) | Azure / Avatar 工作区:可编辑分层母稿 + 真实 `eve.Avatar` 图层渲染 |
|
||||
| [vrm-avatar](vrm-avatar/README.md) | VRM 角色加载与渲染示例 |
|
||||
| [voxel](voxel/README.md) | 流式体素地形 + 方块建造:脚本图集、流式 chunk 与 DDA 拾取 |
|
||||
| [voxel-terrain](voxel-terrain/README.md) | 体素引擎 + 地形生成:32³ chunk、贪婪矩形合并、视锥/视距裁剪、顶点 AO |
|
||||
| [fluid3d](fluid3d/README.md) | 体积流体实验室:19 个场景模式(水 / 黏性 / 烟雾 / 颗粒 / 射流 / 网格化 / 多相 / 热耦合 / 泡沫 / 移动 SDF / 重叠查询 / 尾流…)+ 70 余个 opt-in 数值验收脚本 |
|
||||
|
||||
## 程序化生成
|
||||
|
||||
@@ -67,15 +121,41 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
|---|---|
|
||||
| [procgen](procgen/README.md) | 六种地图算法(BSP / Cellular / Drunkard / Maze / 地形 / WFC)+ 纹理配方 |
|
||||
| [procgen-script-pipeline](procgen-script-pipeline/README.md) | 纯脚本 PointSet 组合、确定性 seed、事务式 hot reload |
|
||||
| [tileworld-graph-dungeon](tileworld-graph-dungeon/README.md) | TileWorldCreator 风格 GridGraph + PointGraph + MeshGraph 混合地牢、分层构建、增量簇更新与可选物理碰撞 |
|
||||
| [pcg-biome](pcg-biome/README.md) | UE PCG 风格空间数据、多层运行时 Cell、时间预算与 Scene 实例批次 |
|
||||
| [pcg-runtime-orchestration](pcg-runtime-orchestration/README.md) | Pcg 运行时编排的数值契约:运行时盖章器 + 生成进度 + 任务队列(无 GPU 场景,等价脚本级单测) |
|
||||
| [pcg-location-system](pcg-location-system/README.md) | 位置书签:相机位姿 + 玩家位姿 + 控制器 + 场景名的命名书签存取与取景 |
|
||||
| [pcg-layer-culling](pcg-layer-culling/README.md) | 按渲染层设置相机与阴影剔除距离(layer 8:相机 32 / 阴影 26),远处同层塔必须被剔除 |
|
||||
| [pcg-scene-player-culling](pcg-scene-player-culling/README.md) | PcgScenePlayer 地形剔除:包围盒 + `terrain` 标签驱动节点可见性,无标签节点保持可见 |
|
||||
| [pcg-depth-of-field](pcg-depth-of-field/README.md) | Pcg 自动景深对焦:视线命中距离驱动对焦状态机,输出写回相机景深(GBuffer 合成路径) |
|
||||
| [pcg-detail-overwrite](pcg-detail-overwrite/README.md) | 地形细节覆盖:PointSet 草地点 + 程序化 albedo/normal/mask 烘焙植被,再覆盖细节距离 / 密度 / 每 patch 分辨率 |
|
||||
| [pcg-snow-wind](pcg-snow-wind/README.md) | 天气雪的世界空间风场:三分量写入与读回校验(非相机跟随偏移) |
|
||||
| [pcg-thunder-strike](pcg-thunder-strike/README.md) | 雷击事件:确定性 seed 决定落点 / 半径 / 音频片段索引,并逐帧驱动点光源衰减 |
|
||||
| [pcg-ui-scaler](pcg-ui-scaler/README.md) | UI 父级缩放:按画布高度自适应并夹紧到上限,求值结果直接落地成面板 |
|
||||
| [pcg-underwater](pcg-underwater/README.md) | 水下效果全链路:深度雾与颜色梯度、时间驱动后期、水焦散 cookie、水下材质、三路音频、粒子与触发体 |
|
||||
| [roguelike-generator](roguelike-generator/README.md) | 种子驱动的房间走廊地牢:autotile、装饰、2D ↔ 2.5D |
|
||||
| [dungeon-generator-3d](dungeon-generator-3d/README.md) | `procgen.generate("level.roguelike", ...)` 的 3D 可配置呈现 |
|
||||
| [tree-generator](tree-generator/README.md) | 确定性树木:Weber-Penn / 空间殖民两种骨架算法 |
|
||||
| [bush-generator](bush-generator/README.md) | 低多边形灌木配方(mesh.bush)与参数实时调节 |
|
||||
| [bush-fog-volumes](bush-fog-volumes/README.md) | 程序化灌木穿过全局与局部体积雾的深度、透明度和多视角检查 |
|
||||
| [rock-generator](rock-generator/README.md) | 岩石配方:变形 + 程序化石材纹理 + 自动 LOD |
|
||||
| [castle-generator](castle-generator/README.md) | 城堡配方 `mesh.castle`:同心结构、塔楼与程序化材质 |
|
||||
| [cave-generator](cave-generator/README.md) | 喀斯特洞穴配方 `mesh.cave`:确定性生成与形态参数 |
|
||||
| [skyscraper-generator](skyscraper-generator/README.md) | 退台塔楼配方:窗格、尖顶、程序化立面纹理 |
|
||||
| [urban-generator](urban-generator/README.md) | 城市配方 `mesh.urban` / `urban.parcels`:街区与地块 |
|
||||
| [housegen](housegen/README.md) | 房屋布局生成 + GLB kit 实例化 |
|
||||
| [linear-structures](linear-structures/README.md) | 线性可拼接结构:栅栏 / 石墙 / 桥 / 长城 / 树篱 / 拒马 |
|
||||
| [hex-terrain](hex-terrain/README.md) | 无源美术的 3D 六边形世界:`mesh.hexterrain` 配方 |
|
||||
| [hex-terrain-3d](hex-terrain-3d/README.md) | 可交互 3D 六边形地图:高程台地与悬崖、水面、河流、道路、城墙与地物、战争迷雾、单位寻路、程序化地图生成、存档、相机操控与笔刷编辑 |
|
||||
| [hex-planet](hex-planet/README.md) | 球面 hex 地形:二十面体(Goldberg)六边形拓扑、径向高程与台地/悬崖、程序化大陆与海洋、轨道相机(`hexmap` 球面后端) |
|
||||
| [terrain-preview](terrain-preview/README.md) | 直写交换链的 3D 地形预览:三种侵蚀 + 河湖水面 + 自动抓帧 |
|
||||
| [terrain-gallery](terrain-gallery/README.md) | 三个固定 seed 在相同生成参数、光照与材质下的并排对比 |
|
||||
| [mesh-modifier-lab](mesh-modifier-lab/README.md) | 13 种网格变形变体并排对照:类型化修饰图(bend / twist / FFD / 切面 / 样条 / 声波)、雕刻笔刷、粘液回弹、Mesh Fit、顶点编辑器 |
|
||||
| [spline-tube-lab](spline-tube-lab/README.md) | 无源网格生成:样条路径驱动管道 / 带状体 / 自定义截面挤出,含开放与闭合回路、分块、分布采样与行进帧 |
|
||||
| [procedural-road](procedural-road/README.md) | 程序化多层立交:截面挤出道路/桥墩/标线/导航逻辑叠加(`mesh.roadNetwork`) |
|
||||
| [mesh-impact-lab](mesh-impact-lab/README.md) | Box3D 命中事件驱动网格塑性冲击:法向冲量 → 形变会话 → 重新上传 |
|
||||
| [geometry-stroke-lab](geometry-stroke-lab/README.md) | 几何笔刷轨迹:quad / 三棱柱 / 立方体截面、平面与空间输入、最小间距过滤、撤销、CPU 生成 + GPU 上传 |
|
||||
| [terrain-stamping](terrain-stamping/README.md) | 地形盖章:旋转印章、距离遮罩、对比度、阶地、平滑与热力核,含 Pcg GTS 雪 / 雨 albedo 分支 |
|
||||
|
||||
## 渲染与效果
|
||||
|
||||
@@ -83,20 +163,55 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
|---|---|
|
||||
| [outline](outline/README.md) | 屏幕空间描边(G-buffer 深度 + 法线) |
|
||||
| [waterfall-demo](waterfall-demo/README.md) | 瀑布流动着色器(条纹 / 湍流 / 泡沫) |
|
||||
| [silpom-ssdm-compare](silpom-ssdm-compare/README.md) | 平面 SilPOM vs SSDM:硬砖 heightfield、轮廓挤出与 FragDepth 取舍对比 |
|
||||
| [virtualgeometry](virtualgeometry/README.md) | 虚拟几何体:cluster DAG + GPU 剔除 + 软件光栅化(Vulkan/WebGPU) |
|
||||
| [atmospheric-fog](atmospheric-fog/README.md) | 大气 froxel 体积雾:最小的完整渲染路径与参数调试 |
|
||||
| [particle-playback-lab](particle-playback-lab/README.md) | 粒子回放实验室:确定性播放、版本化多发射器与视觉验证 |
|
||||
| [rendering-chain-lab](rendering-chain-lab/README.md) | 渲染链运行时对比:TAA / SSR / RTGI 与自动反射链开关(Space / R) |
|
||||
| [shader-live-preview](shader-live-preview/README.md) | 实时 GLSL 预览:编辑 `shaders/preview.frag` 保存即热重载 |
|
||||
| [virtual-texture-blending](virtual-texture-blending/README.md) | 虚拟纹理材质混合:常驻虚拟页 + fallback 槽与无缝 gutter |
|
||||
| [ink-arena](ink-arena/README.md) | GPU 表面喷墨:片元着色器向持久 RGBA8 画布绘制 |
|
||||
| [uv-texture-paint](uv-texture-paint/README.md) | 点击网格经 Box3D 三角形命中映射回模型 UV 并绘制贴图 |
|
||||
| [tensor](tensor/README.md) | 张量编译管线:策略网络 / conv / SDPA / 批量模拟(纯计算) |
|
||||
| [interior-mapping](interior-mapping/README.md) | 无室内几何的室内映射:预投影 Atlas、切线空间射线–AABB、伪透视缩放、过程化家具遮挡与掠射菲涅尔 |
|
||||
| [triplanar-decal](triplanar-decal/README.md) | 三维投射贴花:局部空间三平面混合解决单轴投射在立面上的拉伸,含混合锐度对照 |
|
||||
|
||||
## 工具与扩展
|
||||
|
||||
| 示例 | 演示能力 |
|
||||
|---|---|
|
||||
| [composable-editor](composable-editor/README.md) | 可组合编辑器 SDK:C++ Workspace、动态面板、MVVM、ECS,以及游戏/编辑器共享运行时 |
|
||||
| [scene-editor](scene-editor/README.md) | 可组合场景编辑:场景树、选择与属性联动 |
|
||||
| [scene-builder-game](scene-builder-game/README.md) | 游戏内嵌场景建造:运行时可组合的场景编辑会话 |
|
||||
| [level-designer](level-designer/README.md) | 3D 关卡设计工具:引用地形 → 放置白盒 → 调模型 → 出生点 → Motion Matching 试玩 |
|
||||
| [terrain-editor](terrain-editor/README.md) | 运行时生成的地形编辑器:Viewport 内嵌 3D、笔刷、轨道相机 |
|
||||
| [voxel-catalog-editor](voxel-catalog-editor/README.md) | MagicaVoxel 风格体素雕刻:占用网格与撤销 |
|
||||
| [editor-api-v2](editor-api-v2/README.md) | Editor API V2 同构演示:游戏注入命令,玩家通过 discovery → plan → execute 建造场景 |
|
||||
| [animation-clip-editor](animation-clip-editor/README.md) | 动画剪辑编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
|
||||
| [audio-source-editor](audio-source-editor/README.md) | 音频源编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
|
||||
| [biome-rules-editor](biome-rules-editor/README.md) | 生物群系规则编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
|
||||
| [combat-action-editor](combat-action-editor/README.md) | 战斗动作编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
|
||||
| [procgen-script-editor](procgen-script-editor/README.md) | 宿主封装 Squirrel generator(`generators/forest.nut`)的程序化生成编辑器 |
|
||||
| [ui-theme-editor](ui-theme-editor/README.md) | UI 主题编辑示例:组装命名 Theme 工作区 + 实时预览 |
|
||||
| [material-editor](material-editor/README.md) | 材质编辑器:中央 UE5 材质球预览 + 右侧 Shading/Surface/Lighting 参数面板 |
|
||||
| [ai-stage](ai-stage/README.md) | AI 空舞台:scene_director 搭台 kit + MCP,供 Agent 摆物 / 调光 / 截图 / 质检 |
|
||||
| [blender_hot_reload](blender_hot_reload/README.md) | Blender 保存 → GLB → `SceneLoader.load` → 卸载旧 SceneHost 的热重载往返 |
|
||||
| [model-converter](model-converter/README.md) | 驱动 Blender 把 OBJ 转换成 GLB 并回载(modelconverter 插件) |
|
||||
| [archspace-editor](archspace-editor/README.md) | 户型文档烘焙:房间 / 走廊 / 门 / 窗 / 家具 → 带开洞的渲染器中立网格,经 `gfx.newMeshFromArrays` 上传 |
|
||||
|
||||
## 插件与脚本包(不是 eve run 项目)
|
||||
|
||||
这些目录由 CMake、`eve run -r` 或其它工具单独驱动,不参与 `smoke_examples.sh` 的帧循环契约;
|
||||
`scripts/check_examples.py` 的 `NON_RUNNABLE_EXAMPLES` 是它们的唯一登记处。
|
||||
|
||||
| 示例 | 演示能力 |
|
||||
|---|---|
|
||||
| [native-plugin](native-plugin/README.md) | 原生插件:用 SDK 编译动态库,脚本直接调用 |
|
||||
| [live2d-backend-plugin](live2d-backend-plugin/README.md) | Live2D 后端替换骨架:不改游戏脚本换 Cubism 运行时 |
|
||||
| [agent](agent/README.md) | Squirrel 强化学习:定义环境、训练策略、选择动作与回放(C++ 侧 `eve.Agent`) |
|
||||
| [economy](economy/README.md) | `eve.Economy` 无窗口脚本演示:采集循环与满仓浪费(`eve run -r`) |
|
||||
| [shader_effect_package](shader_effect_package/README.md) | 打包式 shader effect 资产包(`effect.vert` / `effect.frag` + `parameters.json`) |
|
||||
| [surface-fluid-dynamic](surface-fluid-dynamic/README.md) | C++ 侧表面流体参考实现(确定性 CPU 解算) |
|
||||
|
||||
## 与设计目标对照
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Autotile Production — 岸线、墙体与瀑布的自动拼接生产示例
|
||||
|
||||
本示例在不使用任何美术资源的前提下,用 `map.newLayer` 建立三层瓦片层,分别演示岸线、
|
||||
墙体与瀑布三类自动拼接(autotile family)语义。它同时覆盖邻接掩码规则登记、矩形批量
|
||||
绘制与擦除、以及循环瓦片动画,可作为自动拼接落地到关卡数据前的最小生产参考。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/autotile-production
|
||||
```
|
||||
|
||||
也可以进入示例目录后直接启动引擎:
|
||||
|
||||
```bash
|
||||
cd examples/autotile-production && ../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
Windows 下引擎可执行文件为 `build/win32-debug/src/engine/eve.exe`。
|
||||
|
||||
## 演示内容
|
||||
|
||||
- 初始化:`eve_init` 先调 `gfx.setBackgroundColor(0.04, 0.06, 0.09, 1.0)`,再在
|
||||
`map == null` 时创建 `eve.Map()`;三层分别由 `persist` 变量 `shore`、`walls`、
|
||||
`falls` 持有,已存在时不会重复建立。
|
||||
- 岸线层:`map.newLayer(14, 10, 32.0, 32.0)` 建出 14×10、32 像素瓦片的层并
|
||||
`setOrigin(32.0, 48.0)` 定位;`defineAutotileFamily(1, "shore", 1337)` 注册地形 1,
|
||||
随后用 `mask` 从 0 到 255 的循环为全部邻接掩码各登记一条规则
|
||||
`setAutotileRule(1 + mask % 12, 1, mask, 1)`(gid 在 1–12 之间循环复用)。
|
||||
- 矩形批量编辑:`paintTerrainRect(1, 1, 9, 7, 1)` 一次铺满整块地形,再用
|
||||
`eraseTerrainRect(7, 1, 3, 2)` 与 `eraseTerrainRect(1, 6, 2, 2)` 各挖一个缺口,
|
||||
让岸线掩码随邻接关系变化。
|
||||
- 墙体层:`map.newLayer(8, 10, 32.0, 32.0)`、`setOrigin(520.0, 48.0)` 与
|
||||
`defineAutotileFamily(2, "wall", 7)` 之后,按掩码分别绑定顶部(gid 21 / 掩码 32)、
|
||||
墙体主体(gid 22 / 掩码 34)与墙脚(gid 23 / 掩码 2),两段 `paintTerrainRect`
|
||||
画出高度不同的两堵墙。
|
||||
- 瀑布层:`map.newLayer(6, 10, 32.0, 32.0)`、`setOrigin(720.0, 48.0)` 与
|
||||
`defineAutotileFamily(3, "waterfall", 11)` 绑定水唇(掩码 32)、循环主体(掩码 34)
|
||||
和溅落 / 墙脚(掩码 2);再用 `addTileAnimationFrame(32, 32, 140)`、
|
||||
`addTileAnimationFrame(32, 34, 140)`、`addTileAnimationFrame(32, 35, 140)` 让主体
|
||||
瓦片以 140 毫秒换帧循环播放。
|
||||
- 每帧驱动:`eve_update(dt)` 调用 `map.update(dt)` 推进瓦片动画,`eve_render` 依次
|
||||
调用 `gfx.clear()` 与 `map.render(gfx)` 重绘三层。
|
||||
|
||||
## 操作
|
||||
|
||||
`main.nut` 不轮询任何键盘或鼠标输入,示例里没有任何按键或鼠标绑定:三层地形与全部
|
||||
自动拼接规则都在 `eve_init` 中一次性建立,运行时只按帧推进动画与重绘。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `main.nut`:示例全部逻辑——三层创建、family 与邻接掩码规则登记、矩形绘制 / 擦除、
|
||||
瓦片动画,以及 `eve_update` / `eve_render` 的每帧更新与渲染。
|
||||
- `config.nut`:窗口配置,标题为 `EVEngine Autotile Production`,尺寸 960×540,
|
||||
`debug = false`。
|
||||
- `README.md`:本文件。
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
print("EVEngine v0.5.1")
|
||||
print("EVEngine v0.5.2")
|
||||
print("-----------------------------------------")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"content":{"actionId":"combat:follow-up-kaykit","animationSections":[{"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","blendCurve":"ease-in-out","blendInNs":0,"endNs":320000032,"id":"kaykit-section:anticipation","sourceEndNs":0,"sourceStartNs":0,"startNs":0},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop","blendCurve":"ease-in-out","blendInNs":64000004,"endNs":661333376,"id":"kaykit-section:strike","sourceEndNs":0,"sourceStartNs":0,"startNs":320000032},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block","blendCurve":"ease-in-out","blendInNs":53333340,"endNs":1066666752,"id":"kaykit-section:recovery","sourceEndNs":0,"sourceStartNs":0,"startNs":661333376}],"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","durationNs":1066666752,"metadata":{"character":"asset://kaykit/Knight.glb","sourceLicense":"CC0-1.0","variant":"follow-up","weapon":"asset://kaykit/sword_1handed.gltf"},"montage":{"animationLayer":0,"basePlayRate":1,"blendOutOffsetNs":0,"defaultBlendInNs":120000000,"defaultBlendOutNs":150000000,"footIk":false,"looping":false,"rootMotionHorizontal":true,"rootMotionRotation":true,"rootMotionVertical":false},"schema":"eve.action.timeline","schemaVersion":3,"splitTimestampsNs":[320000032,661333376],"tracks":[{"id":"kaykit-track:gameplay","kind":"gameplay","label":"Gameplay","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:damage","payload":{"amount":18,"damageType":"Damage.Physical.Slash"},"timeNs":490666688,"type":"combat:damage"}],"states":[{"endNs":661333376,"id":"kaykit-state:hitbox","payload":{"hitbox":"weapon.main"},"startNs":320000032,"type":"combat:hitbox-window"},{"endNs":917333440,"id":"kaykit-state:combo","payload":{"input":"Ability.Combat.Attack.Light"},"startNs":693333312,"type":"input:combo-window"}]},{"id":"kaykit-track:presentation","kind":"effect","label":"Presentation","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:swing-audio","payload":{"uri":"asset://audio/sword-whoosh"},"timeNs":341333344,"type":"presentation:audio"},{"id":"kaykit-notify:swing-vfx","payload":{"lifetimeSeconds":0.44999998807907104,"uri":"asset://vfx/sword-arc"},"timeNs":362666688,"type":"presentation:vfx"},{"id":"kaykit-notify:impact-camera","payload":{"cue":"combat:light-impact","durationSeconds":0.23999999463558197,"fovAmplitude":2,"positionAmplitude":0.079999998211860657,"rotationAmplitude":1.7999999523162842,"seed":47},"timeNs":490666688,"type":"presentation:camera"}],"states":[{"endNs":800000064,"id":"kaykit-state:volume-curve","payload":{"keys":[{"inTangent":0,"interpolation":"cubic","outTangent":2.2000000476837158,"time":0,"value":0.34999999403953552},{"inTangent":0,"interpolation":"cubic","outTangent":0,"time":0.34999999403953552,"value":1},{"inTangent":-0.80000001192092896,"interpolation":"linear","outTangent":0,"time":1,"value":0.55000001192092896}],"operation":"multiply","target":"audio:master-volume"},"startNs":213333360,"type":"presentation:parameter-curve"}]},{"id":"kaykit-track:movement","kind":"movement","label":"Root Motion","locked":false,"muted":false,"notifies":[],"states":[{"endNs":1066666752,"id":"kaykit-state:root-motion","payload":{"mode":"animation"},"startNs":0,"type":"movement:root-motion-window"}]}]},"contentHash":"9d06ede6d3ef561a","revision":1,"schemaVersion":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"guid":"asset.kaykit.follow-up","schemaVersion":1,"type":"eve.action.timeline"}
|
||||
@@ -0,0 +1 @@
|
||||
{"content":{"actionId":"combat:light-attack-kaykit","animationSections":[{"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","blendCurve":"ease-in-out","blendInNs":0,"endNs":320000032,"id":"kaykit-section:anticipation","sourceEndNs":0,"sourceStartNs":0,"startNs":0},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop","blendCurve":"ease-in-out","blendInNs":64000004,"endNs":661333376,"id":"kaykit-section:strike","sourceEndNs":0,"sourceStartNs":0,"startNs":320000032},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block","blendCurve":"ease-in-out","blendInNs":53333340,"endNs":1066666752,"id":"kaykit-section:recovery","sourceEndNs":0,"sourceStartNs":0,"startNs":661333376}],"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","durationNs":1066666752,"metadata":{"character":"asset://kaykit/Knight.glb","sourceLicense":"CC0-1.0","weapon":"asset://kaykit/sword_1handed.gltf"},"montage":{"animationLayer":0,"basePlayRate":1,"blendOutOffsetNs":0,"defaultBlendInNs":120000000,"defaultBlendOutNs":150000000,"footIk":false,"looping":false,"rootMotionHorizontal":true,"rootMotionRotation":true,"rootMotionVertical":false},"schema":"eve.action.timeline","schemaVersion":3,"splitTimestampsNs":[320000032,661333376],"tracks":[{"id":"kaykit-track:gameplay","kind":"gameplay","label":"Gameplay","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:damage","payload":{"amount":18,"damageType":"Damage.Physical.Slash"},"timeNs":490666688,"type":"combat:damage"}],"states":[{"endNs":661333376,"id":"kaykit-state:hitbox","payload":{"hitbox":"weapon.main"},"startNs":320000032,"type":"combat:hitbox-window"},{"endNs":917333440,"id":"kaykit-state:combo","payload":{"input":"Ability.Combat.Attack.Light"},"startNs":693333312,"type":"input:combo-window"}]},{"id":"kaykit-track:presentation","kind":"effect","label":"Presentation","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:swing-audio","payload":{"uri":"asset://audio/sword-whoosh"},"timeNs":341333344,"type":"presentation:audio"},{"id":"kaykit-notify:swing-vfx","payload":{"lifetimeSeconds":0.44999998807907104,"uri":"asset://vfx/sword-arc"},"timeNs":362666688,"type":"presentation:vfx"},{"id":"kaykit-notify:impact-camera","payload":{"cue":"combat:light-impact","durationSeconds":0.23999999463558197,"fovAmplitude":2,"positionAmplitude":0.079999998211860657,"rotationAmplitude":1.7999999523162842,"seed":47},"timeNs":490666688,"type":"presentation:camera"}],"states":[{"endNs":800000064,"id":"kaykit-state:volume-curve","payload":{"keys":[{"inTangent":0,"interpolation":"cubic","outTangent":2.2000000476837158,"time":0,"value":0.34999999403953552},{"inTangent":0,"interpolation":"cubic","outTangent":0,"time":0.34999999403953552,"value":1},{"inTangent":-0.80000001192092896,"interpolation":"linear","outTangent":0,"time":1,"value":0.55000001192092896}],"operation":"multiply","target":"audio:master-volume"},"startNs":213333360,"type":"presentation:parameter-curve"}]},{"id":"kaykit-track:movement","kind":"movement","label":"Root Motion","locked":false,"muted":false,"notifies":[],"states":[{"endNs":1066666752,"id":"kaykit-state:root-motion","payload":{"mode":"animation"},"startNs":0,"type":"movement:root-motion-window"}]}]},"contentHash":"b6ff257dc806bb14","revision":1,"schemaVersion":1}
|
||||
@@ -0,0 +1 @@
|
||||
{"guid":"asset.kaykit.light-attack","schemaVersion":1,"type":"eve.action.timeline"}
|
||||
@@ -3,8 +3,12 @@
|
||||
This example composes EVEngine's UI-neutral editor SDK into a project-specific
|
||||
combat action editor. It uses the canonical `eve.action.timeline` asset model,
|
||||
native hit testing and drag preview, one-step transactions, undo/redo, and
|
||||
deterministic preview events. The 3D viewport is driven by the same preview
|
||||
cursor as the timeline.
|
||||
deterministic preview events. Its document toolbar opens two independently
|
||||
persisted montage assets, projects dirty state into tabs, and protects unsaved
|
||||
work when a tab is closed. The 3D viewport is driven by the same preview cursor
|
||||
as the active timeline. The viewport also runs a two-fighter playtest: edited
|
||||
`combat:damage` payloads flow from Montage runtime events into the canonical
|
||||
Combat damage runtime, while movement and health stay owned by that runtime.
|
||||
|
||||
Run on Windows:
|
||||
|
||||
@@ -14,16 +18,95 @@ make run/win32-debug GAME=examples/combat-action-editor
|
||||
|
||||
Controls:
|
||||
|
||||
- Click **Melee 1H Attack Chop** to select and replay the authored action.
|
||||
- **Play / Pause** and **Restart** control deterministic action preview; Space
|
||||
- Switch between **Light Attack** and **Follow-up** document tabs. An asterisk
|
||||
marks unsaved edits; closing a dirty tab requires a second explicit click to
|
||||
discard its changes.
|
||||
- The save icon or Ctrl+S atomically writes the active document under
|
||||
`Content/Actions`, validates it, and registers its stable GUID sidecar with
|
||||
AssetDB. **Open Asset** provides a searchable picker over indexed, validated
|
||||
Montage documents; selecting an already-open GUID activates its existing tab.
|
||||
- The compact transport icons control deterministic action preview; Space
|
||||
toggles playback.
|
||||
- Click empty timeline space to seek.
|
||||
- Timeline blocks project registry display names, compact resource/parameter
|
||||
details and stable semantic mini-previews for VFX, audio, prefabs, cameras,
|
||||
hitboxes, defense, input and movement. These visuals are derived UI state and
|
||||
never become duplicate fields in the action asset.
|
||||
- Drag an item body to move it; drag a state edge to resize it.
|
||||
- Edit the selected hitbox start/end in the inspector sliders.
|
||||
- Selecting an animation section, instant notify, or state window automatically
|
||||
opens the **Action Block** inspector. Its timing sliders commit validated,
|
||||
undoable transactions; the toolbar deletes the selected block and exposes the
|
||||
same undo/redo history. Audio, VFX and Prefab blocks expose typed resource,
|
||||
playback, lifecycle and shared spatial controls. VFX states can atomically
|
||||
fit their block duration to the trimmed clip at 1.0x speed, or fit the clip
|
||||
end to the current block duration. When the Particles provider is loaded,
|
||||
**Use Asset Duration** reads the real finite emitter/lifetime data instead
|
||||
of asking the author to copy a duration by hand. Audio additionally exposes
|
||||
a random clip URI pool, deterministic pitch variation, spatial blend and
|
||||
attenuation distances, and exit fade controls; the advanced payload JSON
|
||||
remains available for extensions. Each typed edit merges only its fields,
|
||||
preserves unknown payload data, validates the complete result through the
|
||||
notify registry, and commits one undo step. Notify type and payload JSON are
|
||||
also editable for advanced authoring; registered type, Instant/State shape and
|
||||
typed payload contracts are validated before the document changes. **Enabled** persists in schema v4;
|
||||
disabled blocks remain visible and editable but do not emit preview/runtime
|
||||
events. **New Block** opens a compact registry-backed picker for the target
|
||||
track, Instant/State type, duration and payload; valid starter payloads make
|
||||
every built-in type immediately insertable at the playhead. Copy/paste uses
|
||||
the native deep-copy clipboard and pastes relative to the current playhead.
|
||||
Ctrl/Shift-click adds blocks to the selection; the inspector reports the
|
||||
complete selection range and aligns starts or ends. Dragging one selected
|
||||
body moves the whole selection, while copy and delete operate on every
|
||||
selected block in one undoable transaction.
|
||||
Dragging also uses an eight-pixel magnetic threshold to snap against the
|
||||
playhead, physical splits, animation-section edges and other block edges,
|
||||
after applying the deterministic frame grid.
|
||||
The **Track** tab selects and renames tracks, toggles mute/lock state, creates
|
||||
all eight semantic track kinds, deletes tracks, and deep-copies whole tracks
|
||||
across the open document tabs.
|
||||
Selecting an animation section reveals its source URI, exact start/end,
|
||||
blend-in duration, source trim range and blend curve. **New Section** creates
|
||||
validated sections at explicit timeline ranges. Persisted UI state is migrated
|
||||
when newer editor controls are introduced, preserving open document state.
|
||||
The **Montage** tab edits play rate, looping, Foot IK, animation layer,
|
||||
default blend windows and Root Motion masks. Valid changes update both the
|
||||
persisted timeline and the already prepared runtime preview. Its Physical
|
||||
Sections controls add a split at the live playhead, move an indexed split or
|
||||
delete it; every operation preserves strict ordering and participates in the
|
||||
same undo/redo history used by section jumps and adaptive time warping.
|
||||
The **Joint** tab retains skeleton
|
||||
transform and key editing in the same compact side panel.
|
||||
- The Presentation lane includes a cubic master-volume parameter curve. Its
|
||||
sampled shape is drawn inside the block; native inspector/script APIs edit
|
||||
its keys through the same undoable timeline transaction.
|
||||
- The impact camera cue drives the preview's real `CameraController` with a
|
||||
deterministic position, rotation and FOV impulse.
|
||||
- **Undo / Redo** (or Ctrl+Z / Ctrl+Y) operates on the native timeline
|
||||
transaction history.
|
||||
- Use the transport speed controls or `Q` / `E` / `R` for 0.5x / 1.0x / 1.5x
|
||||
playback. The HUD shows the authoritative physical section, section progress,
|
||||
mixer weight and current rate.
|
||||
- Hold the right mouse button over the preview to orbit; use the mouse wheel to
|
||||
zoom.
|
||||
- Use WASD to move the player through `CombatLocomotionRuntime`. When playback
|
||||
crosses the damage notify, target health and reaction update above the
|
||||
viewport. Editing that block changes the next hit without a demo-only damage
|
||||
model.
|
||||
The active editor snapshot is also registered as an `AbilityDefinition`; its
|
||||
physical sections become action phases, and the preview HUD reports the native
|
||||
Ability phase and cooldown instead of maintaining script timers.
|
||||
The skinned KayKit character consumes the Montage runtime pose directly, so
|
||||
section cross-fades, jumps and blend-out are visible on the real skeleton.
|
||||
Joint/key edits deep-copy the rebuilt clip into the running Montage and resample
|
||||
the current cursor without retaining editor-owned clip pointers.
|
||||
Saving hot-reloads that definition without revoking its grant. Press `J` while
|
||||
the authored `input:combo-window` is open to resolve its gameplay tag through
|
||||
`matchingAbilities`, replace the active exclusive Ability, and replay the
|
||||
montage through the fixed dual-slot coordinator. The outgoing execution keeps
|
||||
its clip snapshot and fades out while the replacement fades in, so combo
|
||||
transitions remain continuous. Input outside the authored window is rejected.
|
||||
Naturally completed montages retain their final weighted pose until another
|
||||
montage cross-fades it out, avoiding a one-frame snap back to the bind pose.
|
||||
|
||||
The preview and timeline headers show the current selection, playback state,
|
||||
edit revision and the last emitted event so every interaction has visible
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Composable Rebellion — 引擎只存事实、玩法语义全在脚本的叛乱示例
|
||||
|
||||
本示例演示"通用能力 + 项目语义"的分层:引擎侧的 `eve.*` 模块只保存中性的属性、
|
||||
归属、授权、指令、事件等事实,完全不认识"忠诚""叛乱"这类领域概念。全部政治含义
|
||||
写在 `simulation.nut` 里,由一次拖欠军饷的薪资事件推动将领带着基地与军队倒向边疆阵营。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/composable-rebellion
|
||||
```
|
||||
|
||||
也可以进入示例目录直接启动引擎:
|
||||
|
||||
```bash
|
||||
cd examples/composable-rebellion && ../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
Windows 下可执行文件为 `build/win32-debug/src/engine/eve.exe`。
|
||||
|
||||
## 演示内容
|
||||
|
||||
- 模块装配:`reset_demo` 创建 `eve.Attributes`、`eve.Social`、`eve.Orders`、`eve.Tags`、`eve.Effects`、
|
||||
`eve.GameEvent`、`eve.Transaction`、`eve.StatePatch`、`eve.Definitions`、`eve.Authority`、`eve.Production`、
|
||||
`eve.PolicyRegistryModule`、`eve.Sensing`、`eve.Steering`、`eve.Decision`——引擎侧没有叛乱专用类型。
|
||||
- 事实与授权:`setBase` 写入 `administration` 80、`loyalty` 48、`ambition` 82 与 `production_speed` 1.0;
|
||||
`setOwner` / `assign` / `setRelation` 建立 `general.arden`、`base.north`、`army.first` 的归属和
|
||||
`officer.vela` 对将领的 `support` 关系;`Authority.grant` 授予 `govern_base` / `command_army`,
|
||||
`PolicyRegistryModule.select` 在 `administration` 频道选中 `governor_bonus`。
|
||||
- 生产与 AI:`Production.setSlotCount` / `enqueue` 在同一基地混放 `build_unit`(`tank.medium`) 与
|
||||
`issue_decree`(`decree.tax_reform`),`advance` 按 `production_speed` 最终值推进;
|
||||
`Sensing.circle` 查到最近敌军后交给 `Decision.setState` / `addTransition` / `trigger` / `choose`,
|
||||
`Steering.arrive` 求朝向速度,`Decision.sample("threat", ...)` 采样 8×8 威胁网格。
|
||||
- 状态效果与判定:`Effects.apply` 施加 `salary_unpaid`,`addTag("politics")` 打标签、payload 写入
|
||||
`loyalty_delta`,再用 `addModifier` 叠加 `loyalty` 负修正;`evaluate_rebellion` 按
|
||||
`getFinal("loyalty")`、`ambition`、`relation(..., "support")` 三重阈值判定,并由
|
||||
`Transaction.create` / `stage` / `markValid` / `validate` / `commit` 提交三步 `set_owner` 计划。
|
||||
- 原子切换与投影:`StatePatch.newBatch` + `setExpected(..., "faction.frontier", "faction.crown")`
|
||||
校验后一次 `commit`,冲突则 `plan.fail("ownership_conflict")`;成功后 `Orders.newQueueOwned` /
|
||||
`append` 下发 `secure_assets`,`GameEvent.append` 记录 `rebellion_started`,
|
||||
`Social.setOwner` / `setRelation` 只作为查询投影同步。
|
||||
|
||||
## 观察方式
|
||||
|
||||
启动时 `eve_init` 调用 `run_rebellion_scenario()`,只打印一行
|
||||
`rebellion=... base_owner=... production=...`:`rebellion` 为 `true` 且 `base_owner` 为
|
||||
`faction.frontier`,说明事务提交与状态补丁都成功;`production` 不再是初始的 `1.0`,因为
|
||||
`refresh_governor_bonus` 用 `addModifier("governor.production", "production_speed", ...,`
|
||||
`"multiply", ...)` 挂了州长加成。任一步失败时会经 `decision_result_ok` 打印
|
||||
`decision <operation> failed: <status.summary>` 便于定位;`eve_render` 用 `gfx.drawSolidRect` 画
|
||||
左右两块,叛乱成功后左块(王权)红通道由 0.65 降至 0.18、右块(边疆)绿通道由 0.20 升至 0.72。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `main.nut`:入口——设置背景色、调用 `run_rebellion_scenario()`、打印结果并渲染两个色块。
|
||||
- `simulation.nut`:全部玩法逻辑——模块装配、生产与 AI、状态效果、叛乱事务与状态补丁。
|
||||
- `config.nut`:窗口 960×540、标题 `Composable Gameplay: Rebellion`、`debug` 与 `hotReload` 均为 `true`。
|
||||
- `README.md`:本说明。
|
||||
@@ -0,0 +1,43 @@
|
||||
# Economy — 采集循环与满仓浪费的 economy 模块演示
|
||||
|
||||
`economy_demo.nut` 是一个**无窗口**的脚本演示,用来快速验证 `eve.Economy` 的资源账本、
|
||||
`eve.GatherNode` 矿点与 `eve.Collector` 采集者的最小闭环:不做渲染,纯逻辑跑完整条
|
||||
「接近 → 采集 → 满载 → 返回 → 卸货入账」链路并打印逐步事件。
|
||||
|
||||
## 运行
|
||||
|
||||
该目录没有项目级 `main.nut` / `config.nut`,用 `-r` 直接运行根脚本(在仓库根目录执行):
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
build/win32-debug/src/engine/eve.exe run -r examples/economy/economy_demo.nut
|
||||
|
||||
# Linux / macOS
|
||||
build/linux-debug/src/engine/eve run -r examples/economy/economy_demo.nut
|
||||
```
|
||||
|
||||
## 演示内容
|
||||
|
||||
- **场景 1 · SC2 式矿物采集**:注册 `minerals` 资源(储量 100、有限),创建一个双槽位
|
||||
`eve.GatherNode`(储量 30)与两个 `eve.Collector`(A:载荷 5 / 速率 1 / 单程 2 tick,
|
||||
B:载荷 3 / 速率 2 / 单程 3 tick),`assign()` 占槽后按 tick 推进到双方 `isIdle()`,
|
||||
打印每个 `gather / deposit` 事件、往返次数、累计入账与矿点余量。
|
||||
- **场景 2 · 满仓浪费**:玩家持有上限 10、节点储量 25、载荷 5,第三次往返起超出上限的部分
|
||||
计入 `waste` 事件,用于展示 `eco.getWasted()` 与 `eco.getCap()` 的语义。
|
||||
|
||||
## 输出
|
||||
|
||||
脚本是确定性的:同样参数下 `doneTick`、往返次数与余额完全一致。结尾会打印两个场景的
|
||||
余额 / 上限 / 浪费统计,例如:
|
||||
|
||||
```
|
||||
玩家1 矿物余额: ... / 上限 100, 浪费 0
|
||||
矿点剩余: 0
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `economy_demo.nut` | 演示脚本本体(唯一的入口,用 `eve run -r` 运行) |
|
||||
| `README.md` | 本文档 |
|
||||
@@ -0,0 +1,77 @@
|
||||
# Fluid3D Lab(3D 体积流体实验室)
|
||||
|
||||
交互式 3D 体积流体 / 颗粒实验室:一个可运行项目里覆盖 19 个场景模式(`mode` 0–18),
|
||||
外加 70 多个 opt-in 数值验收脚本。窗口标题 `EVEngine | Fluid3D Lab`,启动后默认**暂停**。
|
||||
|
||||
演示的能力:体积流体求解器(SPH 类)的材质 / 相态、表面重建与各向异性、颗粒发射器与
|
||||
生命周期、碰撞体与接触抓取、热耦合、泡沫池、移动 SDF 障碍、重叠查询、网格化输出,
|
||||
以及相机与渲染设置(折射 / 反射 / 粒子 impostor / 正交相机)等。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd examples/fluid3d
|
||||
../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
或从仓库根目录:
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/fluid3d # <platform> = win32 / linux / macosx
|
||||
```
|
||||
|
||||
## 操作
|
||||
|
||||
| 按键 | 作用 |
|
||||
|---|---|
|
||||
| `Space` | 暂停 / 继续(启动时是暂停状态) |
|
||||
| `R` | 按当前模式重建场景 |
|
||||
| `1`–`9` | Water / Viscous / Smoke / Granular / Jet / Mesh / Moving / Multiphase / Wheel |
|
||||
| `0` | Mixing(左侧加热、右侧冷却,颜色跟随黏度) |
|
||||
| `T` | Thermal(热耦合与热色) |
|
||||
| `S` | Solid attachment(固体附着,跟随运动碰撞体) |
|
||||
| `F` | Foam(泡沫池与合成耗时) |
|
||||
| `D` | SDF(只做变换更新的移动 SDF 障碍) |
|
||||
| `Q` | Query(重叠触发器,红 / 黄计数) |
|
||||
| `M` | Maze(迷宫;模式内用 `A` / `D` 调角速度) |
|
||||
| `B` | Bucket(水桶) |
|
||||
| `W` | Bottle(威士忌瓶;按住 `D` 倾倒) |
|
||||
| `K` | Wake(卡门涡街尾流) |
|
||||
|
||||
HUD 会实时打印粒子数、掉帧丢弃的秒数以及 Sim / Surface / Upload 三个阶段的耗时;
|
||||
求解失败时窗口内显示错误提示,控制台打印 `VOLUME_FLUID_STOPPED: <原因>`。
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `main.nut` | 入口:模式注册、按键分发、每帧求解与表面上传 |
|
||||
| `config.nut` | 引擎 `config`:960×720、`hotReload = true` |
|
||||
| `maze-support.nut` | 迷宫模式的几何与状态 |
|
||||
| `bucket-support.nut` | 水桶模式 |
|
||||
| `bottle-support.nut` | 威士忌瓶模式(姿态更新的 SDF) |
|
||||
| `karman-support.nut` | 卡门涡街尾流模式 |
|
||||
| `wave-support.nut` | 波浪发生器 |
|
||||
| `*-check.nut` | opt-in 数值验收脚本(见下) |
|
||||
| `assets/` | OBJ 网格(发射环、酒瓶等) |
|
||||
| `fonts/` | `DejaVuSans-Bold.ttf` 与授权说明 |
|
||||
| `eve_snapshot.json` | 示例快照 |
|
||||
|
||||
## opt-in 数值验收脚本
|
||||
|
||||
`*-check.nut` 都是**可选**探针,不会被 `main.nut` 自动加载;在运行中的示例里按需加载并
|
||||
调用对应函数,例如:
|
||||
|
||||
```squirrel
|
||||
dofile("thermal-check.nut"); verifyVolumeFluidThermalContact();
|
||||
dofile("soak-check.nut"); beginVolumeFluidSoak();
|
||||
```
|
||||
|
||||
它们用固定的步数与数值容差验证求解结果(粒子数、位移下限、表面能量等),失败时直接
|
||||
`throw`。完整的脚本清单、每个探针覆盖的 API 与判定条件见
|
||||
[`docs/usr/fluid3d.md`](../../docs/usr/fluid3d.md)。
|
||||
|
||||
## 契约
|
||||
|
||||
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
|
||||
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
|
||||
@@ -0,0 +1,105 @@
|
||||
# hex-planet
|
||||
|
||||
The `hexmap` module's hex terrain, wrapped onto a sphere and viewed from orbit.
|
||||
|
||||
This is the spherical counterpart of [`examples/hex-terrain-3d`](../hex-terrain-3d):
|
||||
the same module, the same `HexCellData` / `HexValues` / `HexFlags` cell records, the
|
||||
same `HexMetrics` geometry constants, the same `HexMeshData` container and the same
|
||||
`HexTerrainVertexCode` vertex encoding. What changes is the surface: an icosahedral
|
||||
(Goldberg) topology instead of a rectangular chunk grid, and a radial "up" instead of
|
||||
world Y.
|
||||
|
||||
```
|
||||
make run/win32-debug GAME=examples/hex-planet # Windows
|
||||
make run/linux-debug GAME=examples/hex-planet # Linux (headless: see AGENTS.md)
|
||||
```
|
||||
|
||||
## What it looks like
|
||||
|
||||
Orbit camera around a generated planet, with a hexagonal tessellation you can read at
|
||||
the limb and in the faceted coastlines. `hex-planet.png` in this directory is captured
|
||||
by the example itself after 90 frames. The default pose is static, so two runs agree to
|
||||
within the ImGui overlay: measured across two runs, **25 of 1,024,000 pixels differ**
|
||||
(the HUD), and the globe itself is identical.
|
||||
|
||||
## Controls
|
||||
|
||||
| Input | Action |
|
||||
|---|---|
|
||||
| LMB drag | orbit / pitch |
|
||||
| wheel | zoom |
|
||||
| `space` | toggle auto-spin |
|
||||
| `R` | new seed |
|
||||
| `[` `]` | subdivision level down / up |
|
||||
| `-` `=` | land share down / up |
|
||||
| `esc` | reset the view |
|
||||
| `F5` | save `hex-planet.png` |
|
||||
|
||||
## How it is put together
|
||||
|
||||
Everything geometric is C++; the script owns only the camera, the input mapping, the
|
||||
two renderables and the HUD.
|
||||
|
||||
| Piece | Where | Role |
|
||||
|---|---|---|
|
||||
| `HexSphereTopology` | `src/modules/hexmap` | icosahedral cell graph: ids, neighbours, edge directions, corners |
|
||||
| `HexSphereMap` | `src/modules/hexmap` | editable sphere: cell records, radial elevation, picking, brushes, dirty cells |
|
||||
| `HexSphereMesh` | `src/modules/hexmap` | builds the terrain and ocean meshes of the whole sphere |
|
||||
| `HexSphereGenerator` | `src/modules/hexmap` | continents, sea level, biomes from 3D noise |
|
||||
| script bindings | `HexMapModule` | `hexmap.sphere*` |
|
||||
| this directory | — | orbit camera, renderables, HUD, shaders |
|
||||
|
||||
### The four things that do not survive the change of surface
|
||||
|
||||
1. **Edge count.** A cell has six edges, or five if it is one of the twelve pentagons.
|
||||
Nothing iterates a fixed six directions, and there is no `opposite(direction)`
|
||||
offset that works everywhere: the sphere map uses `directionOf(cell, other)`
|
||||
throughout, because a hexagon–pentagon edge has no arithmetic opposite.
|
||||
2. **Edge ownership.** The planar builder relies on the chunk grid to emit each shared
|
||||
boundary exactly once. A sphere has no chunk grid, so the lower cell id owns the
|
||||
edge instead — still a total order over the same set of edges.
|
||||
3. **"Vertical" is radial.** Positions are a unit direction times a radius, and the
|
||||
terrace interpolation of a slope moves along the *arc* by the horizontal step and
|
||||
along the *radius* by the vertical one.
|
||||
4. **Shading has no horizon.** The planar terrain shader reads `vWorldPos.y` as
|
||||
altitude, `vNormal.y` as up, projects detail noise into XZ and fades into distance
|
||||
fog. On a sphere Y spans the whole planet, XZ collapses at the poles, the normal is
|
||||
the radial direction, and there is no horizon to fade into. `hex_planet_terrain`
|
||||
keeps the palette and the BRDF byte-for-byte and replaces only those four.
|
||||
|
||||
### Notes for anyone editing the shaders
|
||||
|
||||
The host applies its own Reinhard-style tonemap to this fragment stage's output — a
|
||||
debug shader emitting a constant `1.0` arrives in the frame buffer as `224/255`, i.e.
|
||||
`y = x / (x + 0.139)`. Both planet shaders invert that curve
|
||||
(`hostTonemapInverse`) so the palette they author is the sRGB that actually ships;
|
||||
without it every colour is silently compressed twice and cannot be tuned.
|
||||
|
||||
The two fragment shaders are compiled to SPIR-V, because runtime GLSL compilation is
|
||||
unavailable on Windows:
|
||||
|
||||
```sh
|
||||
glslc -o shaders/hex_planet_terrain.frag.spv shaders/hex_planet_terrain.frag
|
||||
glslc -o shaders/hex_planet_water.frag.spv shaders/hex_planet_water.frag
|
||||
```
|
||||
|
||||
## Relationship to the other hex examples
|
||||
|
||||
| Example | Surface | Recipe / module | Camera |
|
||||
|---|---|---|---|
|
||||
| `hex-terrain-3d` | flat, chunked | `hexmap` (editable map) | orbiting a ground map |
|
||||
| `hex-planet` | spherical, one mesh | `hexmap` sphere backend | orbiting the globe |
|
||||
| `hex-terrain` | flat mesh | `mesh.hexterrain` (procgen) | ground level |
|
||||
|
||||
## Known gaps
|
||||
|
||||
- The ocean is a plain hexagonal cap per flooded cell: it has no shore shelf, no
|
||||
refraction and no shoreline foam, and it does not cover beaches at sea level
|
||||
(`waterLevel == elevation` is not flooded, matching the module's own rule). Sea ice
|
||||
is a shading term on that cap, not separate geometry.
|
||||
- Cliffs are radial walls with no overhang or erosion detail.
|
||||
- The generator has no rivers, erosion or plate tectonics; the planar
|
||||
`HexMapGenerator` did not carry over.
|
||||
- The sphere has no rivers, roads, walls, features, fog-of-war or units, and no save
|
||||
format. The cell *flags* can be set, but there is no spherical geometry, search or
|
||||
serialization behind them.
|
||||
@@ -0,0 +1,9 @@
|
||||
// examples/hex-planet -- orbital view of the spherical hex map.
|
||||
// No `modules` key: the example runs against the full default module set.
|
||||
config = {
|
||||
width = 1280
|
||||
height = 800
|
||||
title = "EVEngine Hex Planet"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
|
After Width: | Height: | Size: 325 KiB |
@@ -0,0 +1,359 @@
|
||||
// examples/hex-planet -- the hex terrain system wrapped onto a sphere, from orbit.
|
||||
//
|
||||
// This is the spherical counterpart of `examples/hex-terrain-3d`: the same module,
|
||||
// the same `HexCellData` / `HexValues` / `HexFlags` records and the same terrain
|
||||
// vertex encoding, but the grid is an icosahedral (Goldberg) topology reached through
|
||||
// `hexmap`'s sphere bindings instead of the flat chunk grid.
|
||||
//
|
||||
// Everything geometric lives in C++ (`HexSphereTopology`, `HexSphereMap`,
|
||||
// `HexSphereMesh`, `HexSphereGenerator`); this script owns the orbit camera, the
|
||||
// input mapping, the two renderables and the HUD.
|
||||
|
||||
const DEG_TO_RAD = 0.017453292519943295;
|
||||
|
||||
// Topology levels: cells = 10 * 4^s + 2. Level 4 is 2562 cells and is the default;
|
||||
// level 6 is 40962 and still generates and builds in well under a second.
|
||||
const SUBDIVISION_MIN = 1;
|
||||
const SUBDIVISION_MAX = 6;
|
||||
|
||||
const DEFAULT_SUBDIVISION = 4;
|
||||
const DEFAULT_RADIUS = 100.0;
|
||||
const DEFAULT_SEED = 20260918;
|
||||
const DEFAULT_LAND = 45;
|
||||
const DEFAULT_WATER_LEVEL = 0;
|
||||
|
||||
// Framing: the globe has to fit the vertical field of view with room for the relief.
|
||||
const FOV_DEGREES = 45.0;
|
||||
const FIT_MARGIN = 1.16;
|
||||
|
||||
const ORBIT_SPEED = 0.30; // degrees of rotation per pixel dragged
|
||||
const PITCH_MIN = -78.0; // never exactly +/-90: the up vector degenerates there
|
||||
const PITCH_MAX = 78.0;
|
||||
const ZOOM_STEP = 0.12;
|
||||
const ZOOM_MIN = 1.12; // as a multiple of the framing distance
|
||||
const ZOOM_MAX = 4.50;
|
||||
const SPIN_SPEED = 4.0; // degrees per second while auto-spin is on
|
||||
|
||||
persist planet = null;
|
||||
|
||||
function createState() {
|
||||
return {
|
||||
camera = null,
|
||||
terrainShader = null,
|
||||
waterShader = null,
|
||||
terrain = null,
|
||||
water = null,
|
||||
|
||||
subdivision = DEFAULT_SUBDIVISION,
|
||||
radius = DEFAULT_RADIUS,
|
||||
seed = DEFAULT_SEED,
|
||||
landPercent = DEFAULT_LAND,
|
||||
waterLevel = DEFAULT_WATER_LEVEL,
|
||||
|
||||
yaw = 35.0,
|
||||
pitch = 38.0,
|
||||
zoom = 1.0,
|
||||
spinning = false,
|
||||
|
||||
dragging = false,
|
||||
lastMouseX = 0,
|
||||
lastMouseY = 0,
|
||||
|
||||
elapsed = 0.0,
|
||||
frames = 0,
|
||||
screenshotSaved = false,
|
||||
statusText = "building...",
|
||||
uiBuilt = false,
|
||||
triangles = 0,
|
||||
};
|
||||
}
|
||||
|
||||
// --- camera -----------------------------------------------------------------
|
||||
|
||||
function fitDistance(st) {
|
||||
return (st.radius * FIT_MARGIN) / sin(FOV_DEGREES * 0.5 * DEG_TO_RAD);
|
||||
}
|
||||
|
||||
function applyCamera(st) {
|
||||
if (st.camera == null) return;
|
||||
local distance = fitDistance(st) * st.zoom;
|
||||
local yawRad = st.yaw * DEG_TO_RAD;
|
||||
local pitchRad = st.pitch * DEG_TO_RAD;
|
||||
local horizontal = cos(pitchRad) * distance;
|
||||
|
||||
st.camera.setEye(horizontal * sin(yawRad), sin(pitchRad) * distance, horizontal * cos(yawRad));
|
||||
// The globe is centred on the origin, so the target is its centre and the relief
|
||||
// is symmetric around it.
|
||||
st.camera.setTarget(0.0, 0.0, 0.0);
|
||||
st.camera.setClipPlanes(distance * 0.02, distance * 3.0);
|
||||
}
|
||||
|
||||
function updateCamera(st, dt) {
|
||||
if (st.spinning) st.yaw += SPIN_SPEED * dt;
|
||||
|
||||
local x = mouse.getX();
|
||||
local y = mouse.getY();
|
||||
local down = mouse.isDown(1);
|
||||
if (down) {
|
||||
if (st.dragging) {
|
||||
st.yaw -= (x - st.lastMouseX) * ORBIT_SPEED;
|
||||
st.pitch = clampf(st.pitch + (y - st.lastMouseY) * ORBIT_SPEED, PITCH_MIN, PITCH_MAX);
|
||||
}
|
||||
st.dragging = true;
|
||||
} else {
|
||||
st.dragging = false;
|
||||
}
|
||||
st.lastMouseX = x;
|
||||
st.lastMouseY = y;
|
||||
|
||||
local wheel = mouse.getWheelY();
|
||||
if (wheel != 0.0) st.zoom = clampf(st.zoom - wheel * ZOOM_STEP, ZOOM_MIN, ZOOM_MAX);
|
||||
|
||||
applyCamera(st);
|
||||
}
|
||||
|
||||
// --- shaders and renderables ------------------------------------------------
|
||||
|
||||
function createShaders(st) {
|
||||
if (st.terrainShader != null) return true;
|
||||
|
||||
// The engine's built-in Mesh3D vertex stage already produces the varyings both
|
||||
// fragment stages consume, so only the fragment stages ship -- as SPIR-V, because
|
||||
// runtime GLSL compilation is unavailable on Windows.
|
||||
local terrain = gfx.loadMeshShaderSpv("", "shaders/hex_planet_terrain.frag.spv");
|
||||
if (!terrain.ok) {
|
||||
print("hex planet: terrain shader failed: " + terrain.status.summary + "\n");
|
||||
return false;
|
||||
}
|
||||
local water = gfx.loadMeshShaderSpv("", "shaders/hex_planet_water.frag.spv");
|
||||
if (!water.ok) {
|
||||
print("hex planet: water shader failed: " + water.status.summary + "\n");
|
||||
return false;
|
||||
}
|
||||
st.terrainShader = terrain.value;
|
||||
st.waterShader = water.value;
|
||||
return true;
|
||||
}
|
||||
|
||||
function createRenderables(st) {
|
||||
if (!createShaders(st)) return;
|
||||
|
||||
st.terrain = eve.Renderable3D();
|
||||
st.terrain.setShader(st.terrainShader);
|
||||
// The globe does not cast onto or receive from anything here: there is one
|
||||
// surface, and self-shadowing it only produces acne along every hex boundary.
|
||||
st.terrain.setCastShadow(false);
|
||||
st.terrain.setReceiveShadow(false);
|
||||
st.terrain.setVisible(false);
|
||||
|
||||
st.water = eve.Renderable3D();
|
||||
st.water.setShader(st.waterShader);
|
||||
st.water.setCastShadow(false);
|
||||
st.water.setReceiveShadow(false);
|
||||
st.water.setVisible(false);
|
||||
}
|
||||
|
||||
function bindMeshes(st) {
|
||||
if (st.terrain == null) return;
|
||||
|
||||
local terrainMesh = hexmap.sphereTerrainMesh();
|
||||
if (terrainMesh != null) {
|
||||
st.terrain.setMesh(terrainMesh);
|
||||
st.terrain.setVisible(true);
|
||||
} else {
|
||||
st.terrain.setVisible(false);
|
||||
}
|
||||
|
||||
local waterMesh = hexmap.sphereWaterMesh();
|
||||
if (waterMesh != null) {
|
||||
st.water.setMesh(waterMesh);
|
||||
st.water.setVisible(true);
|
||||
} else {
|
||||
// Nothing is flooded at this sea level; the renderable must not keep drawing
|
||||
// the previous ocean.
|
||||
st.water.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- world ------------------------------------------------------------------
|
||||
|
||||
function describePlanet(st) {
|
||||
return hexmap.sphereCellCount() + " cells, " + hexmap.spherePentagonCount() + " pentagons, " +
|
||||
hexmap.sphereSubdivision() + " subdivisions";
|
||||
}
|
||||
|
||||
/** Reports what the generator actually produced, so a washed-out frame is measurable. */
|
||||
function reportDistribution(st) {
|
||||
local counts = [0, 0, 0, 0, 0];
|
||||
local underwater = 0;
|
||||
local count = hexmap.sphereCellCount();
|
||||
for (local cell = 0; cell < count; cell++) {
|
||||
local terrain = hexmap.sphereTerrainType(cell);
|
||||
if (terrain >= 0 && terrain < 5) counts[terrain] = counts[terrain] + 1;
|
||||
if (hexmap.sphereIsUnderwater(cell)) underwater = underwater + 1;
|
||||
}
|
||||
print("hex planet: sand " + counts[0] + " grass " + counts[1] + " mud " + counts[2] + " stone " + counts[3] +
|
||||
" snow " + counts[4] + " | underwater " + underwater + " of " + count + "\n");
|
||||
print("hex planet: terrain mesh " + (hexmap.sphereTerrainMesh() != null) + ", water mesh " +
|
||||
(hexmap.sphereWaterMesh() != null) + "\n");
|
||||
}
|
||||
|
||||
function buildPlanet(st) {
|
||||
local created = hexmap.newSphere(gfx, st.subdivision, st.radius, st.seed);
|
||||
if (!created.ok) {
|
||||
st.statusText = "sphere failed: " + created.status.summary;
|
||||
print("hex planet: " + st.statusText + "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel);
|
||||
if (!generated.ok) {
|
||||
st.statusText = "generate failed: " + generated.status.summary;
|
||||
print("hex planet: " + st.statusText + "\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
st.triangles = 0;
|
||||
st.statusText = describePlanet(st);
|
||||
print("hex planet: " + describePlanet(st) + ", radius " + st.radius + ", seed " + st.seed + "\n");
|
||||
print("hex planet: " + st.landPercent + "% land, water line " + st.waterLevel + ", spacing " +
|
||||
hexmap.sphereCellSpacing() + "\n");
|
||||
reportDistribution(st);
|
||||
print("hex planet: LMB drag orbit | wheel zoom | space spin | R new seed | [ ] subdivision | - = land\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
function regenerate(st) {
|
||||
local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel);
|
||||
if (!generated.ok) {
|
||||
st.statusText = "generate failed: " + generated.status.summary;
|
||||
return;
|
||||
}
|
||||
bindMeshes(st);
|
||||
st.statusText = describePlanet(st) + ", seed " + st.seed;
|
||||
}
|
||||
|
||||
// --- input ------------------------------------------------------------------
|
||||
|
||||
function handleKeys(st) {
|
||||
if (key_just_pressed("r")) {
|
||||
st.seed += 1;
|
||||
regenerate(st);
|
||||
print("hex planet: seed " + st.seed + "\n");
|
||||
}
|
||||
if (key_just_pressed("space", "Space")) {
|
||||
st.spinning = !st.spinning;
|
||||
st.statusText = st.spinning ? "auto-spin on" : "auto-spin off";
|
||||
}
|
||||
if (key_just_pressed("escape", "Escape")) {
|
||||
st.spinning = false;
|
||||
st.yaw = 35.0;
|
||||
st.pitch = 38.0;
|
||||
st.zoom = 1.0;
|
||||
applyCamera(st);
|
||||
}
|
||||
if (key_just_pressed("F5")) {
|
||||
if (gfx.saveFramePng("hex-planet.png")) st.statusText = "saved hex-planet.png";
|
||||
}
|
||||
|
||||
// Rebuilding a subdivision level replaces the whole topology, so it goes through
|
||||
// the full create path rather than the cheaper regenerate.
|
||||
local subdivision = st.subdivision;
|
||||
if (key_just_pressed("]")) subdivision = subdivision + 1;
|
||||
if (key_just_pressed("[")) subdivision = subdivision - 1;
|
||||
if (subdivision < SUBDIVISION_MIN) subdivision = SUBDIVISION_MIN;
|
||||
if (subdivision > SUBDIVISION_MAX) subdivision = SUBDIVISION_MAX;
|
||||
if (subdivision != st.subdivision) {
|
||||
st.subdivision = subdivision;
|
||||
buildPlanet(st);
|
||||
bindMeshes(st);
|
||||
}
|
||||
|
||||
local land = st.landPercent;
|
||||
if (key_just_pressed("=")) land = land + 5;
|
||||
if (key_just_pressed("-")) land = land - 5;
|
||||
if (land < 5) land = 5;
|
||||
if (land > 95) land = 95;
|
||||
if (land != st.landPercent) {
|
||||
st.landPercent = land;
|
||||
regenerate(st);
|
||||
print("hex planet: " + land + "% land\n");
|
||||
}
|
||||
}
|
||||
|
||||
// --- HUD --------------------------------------------------------------------
|
||||
|
||||
function buildHud(st) {
|
||||
// Widgets only exist inside a window, so the panel is built once and then updated
|
||||
// by label in `refreshHud`.
|
||||
ui.beginBuild();
|
||||
ui.beginWindow("HexPlanet", "root");
|
||||
ui.text("EVEngine Hex Planet", "title");
|
||||
ui.text("", "planet");
|
||||
ui.text("", "view");
|
||||
ui.separator("sep");
|
||||
ui.text("[LMB]orbit [wheel]zoom [space]spin [R]seed", "help1");
|
||||
ui.text("[[/]]subdivision [-/=]land [F5]shot [esc]reset", "help2");
|
||||
ui.end();
|
||||
ui.mountBuildAs("hud");
|
||||
ui.select("hud");
|
||||
ui.setHostOverlay(true);
|
||||
ui.setHostVisible(true);
|
||||
ui.setHostPos(12.0, 12.0, 0.0, 0.0);
|
||||
st.uiBuilt = true;
|
||||
}
|
||||
|
||||
function refreshHud(st) {
|
||||
if (!st.uiBuilt) return;
|
||||
ui.select("hud");
|
||||
ui.setText("planet", "planet: " + st.statusText);
|
||||
ui.setText("view", "view: yaw " + st.yaw + " pitch " + st.pitch + " zoom " + st.zoom);
|
||||
}
|
||||
|
||||
// --- entry points -----------------------------------------------------------
|
||||
|
||||
eve_init = function() {
|
||||
if (planet == null) planet = createState();
|
||||
local st = planet;
|
||||
|
||||
st.camera = eve.Camera3D();
|
||||
st.camera.setFov(FOV_DEGREES);
|
||||
// The default view looks down on the northern cap (pitch 38) and the light sits well
|
||||
// off the view axis: a lower pitch foreshortens the ice into the very top edge, and a
|
||||
// light near the camera puts every visible pixel at N.L ~ 1, which flattens the globe
|
||||
// into a bright disc with no terminator to read the sphere's shape from.
|
||||
st.camera.setAmbient(0.17, 0.20, 0.27);
|
||||
st.camera.setActive(true);
|
||||
|
||||
gfx.setBackgroundColor(0.012, 0.016, 0.030, 1.0);
|
||||
gfx.setDirectionalLight(-0.78, 0.42, 0.46, 1.45, 1.34, 1.16);
|
||||
gfx.setCloudShadows(0.22, 6.0, 0.0, 0.42, 0.40, 0.52, 0.62);
|
||||
|
||||
createRenderables(st);
|
||||
buildPlanet(st);
|
||||
bindMeshes(st);
|
||||
buildHud(st);
|
||||
applyCamera(st);
|
||||
};
|
||||
|
||||
eve_update = function(dt) {
|
||||
local st = planet;
|
||||
if (st == null) return;
|
||||
st.elapsed += dt;
|
||||
st.frames += 1;
|
||||
|
||||
handleKeys(st);
|
||||
updateCamera(st, dt);
|
||||
refreshHud(st);
|
||||
|
||||
// Readback is enabled by the first call, so retry until the frame lands. The
|
||||
// default pose is static, so the saved frame is reproducible run to run.
|
||||
if (!st.screenshotSaved && st.frames > 90 && gfx.saveFramePng("hex-planet.png"))
|
||||
st.screenshotSaved = true;
|
||||
};
|
||||
|
||||
eve_render = function() {
|
||||
gfx.clear();
|
||||
gfx.render3D();
|
||||
ui.beginFrameAndRender();
|
||||
};
|
||||
@@ -0,0 +1,168 @@
|
||||
#version 450
|
||||
|
||||
// Terrain surface of the spherical hex map (examples/hex-planet).
|
||||
//
|
||||
// The vertex encoding is exactly the one the planar builder emits, so this shader
|
||||
// and `hex_map_terrain.frag` share the decode and the palette (see
|
||||
// HexMapMesh.h / HexTerrainVertexCode):
|
||||
// uv.x = layerA + layerB * 8 + layerC * 64
|
||||
// uv.y = weightB + weightC * 16
|
||||
//
|
||||
// What cannot be shared is everything that quietly assumed a flat map. The planar
|
||||
// shader reads `vWorldPos.y` as altitude, `vNormal.y` as "up", projects its detail
|
||||
// noise into XZ and fades the far distance into fog; on a sphere Y spans the whole
|
||||
// planet, XZ collapses at the poles, the surface normal is the radial direction and
|
||||
// there is no horizon to fade into. Those four are replaced by radial equivalents.
|
||||
// The palette and the BRDF below are deliberately the same numbers.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float s = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail-noise coordinates of a point on the sphere.
|
||||
*
|
||||
* Dropping the dominant axis keeps the field roughly isotropic. Projecting into XZ
|
||||
* as the planar shader does would collapse to a point at both poles, which reads as
|
||||
* a radial smear of stretched noise exactly where the twelve pentagons sit.
|
||||
*/
|
||||
vec2 sphereCoords(vec3 dir) {
|
||||
vec3 a = abs(dir);
|
||||
if (a.y >= a.x && a.y >= a.z) return dir.xz;
|
||||
if (a.x >= a.z) return dir.yz;
|
||||
return dir.xy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverts the host's own tonemap.
|
||||
*
|
||||
* A debug fragment stage that emitted a constant 1.0 arrives in the frame buffer as
|
||||
* 224/255, i.e. the host applies a second, Reinhard-style curve `y = x / (x + 0.139)`
|
||||
* to whatever this stage returns. This shader authors final sRGB values, so it undoes
|
||||
* that curve instead of being silently darkened by it; without this the palette below
|
||||
* cannot be tuned at all, because every change is compressed by an invisible stage.
|
||||
*/
|
||||
vec3 hostTonemapInverse(vec3 srgb) {
|
||||
srgb = clamp(srgb, vec3(0.0), vec3(0.95));
|
||||
return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001));
|
||||
}
|
||||
|
||||
// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow.
|
||||
vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) {
|
||||
if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain);
|
||||
if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3);
|
||||
if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain);
|
||||
if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain);
|
||||
return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35);
|
||||
}
|
||||
float terrainRoughness(int id) {
|
||||
if (id == 0) return 0.92;
|
||||
if (id == 1) return 0.88;
|
||||
if (id == 2) return 0.74;
|
||||
if (id == 3) return 0.68;
|
||||
return 0.42;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float packedIndices = vUV.x;
|
||||
float packedWeights = vUV.y;
|
||||
int layerA = int(mod(packedIndices, 8.0) + 0.5);
|
||||
int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5);
|
||||
int layerC = int(floor(packedIndices / 64.0) + 0.5);
|
||||
float weightB = fract(packedWeights);
|
||||
float weightC = floor(packedWeights) / 16.0;
|
||||
float weightA = max(0.0, 1.0 - weightB - weightC);
|
||||
|
||||
float time = ubo.cloud.z;
|
||||
vec3 radial = normalize(vWorldPos);
|
||||
vec2 p = sphereCoords(radial) * 14.0;
|
||||
float grain = noise(p * 9.0);
|
||||
float patchiness = fbm(p * 1.6);
|
||||
|
||||
vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA
|
||||
+ terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB
|
||||
+ terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC;
|
||||
float roughness = terrainRoughness(layerA) * weightA
|
||||
+ terrainRoughness(layerB) * weightB
|
||||
+ terrainRoughness(layerC) * weightC;
|
||||
|
||||
// The generator already places the snow line, the rock and the deserts, so the
|
||||
// continental scale is in the vertex layers; this only breaks up the flat fills.
|
||||
albedo *= mix(0.90, 1.08, fbm(p * 0.8));
|
||||
|
||||
float detail = fbm(p * 6.0);
|
||||
vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22);
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
float ndh = max(dot(N, H), 0.0);
|
||||
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
|
||||
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
|
||||
float k = pow(roughness + 1.0, 2.0) / 8.0;
|
||||
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
|
||||
vec3 F0 = vec3(0.04);
|
||||
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
|
||||
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
|
||||
// 1.5 rather than the planar shader's 2.6: a globe has no distance fog and far less
|
||||
// self-occlusion, so the same light energy lands on every visible pixel and the
|
||||
// snow and sand fills blow straight through the tonemap.
|
||||
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 1.5;
|
||||
|
||||
// "Up" is the radial direction here, not world Y: N.y would run from -1 at the
|
||||
// south pole to +1 at the north and turn the sky term into a latitude gradient.
|
||||
float up = dot(N, radial);
|
||||
float sky = max(0.0, up) * 0.16 + 0.06;
|
||||
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky);
|
||||
|
||||
// Slope shading so cliffs read as rock even without a texture.
|
||||
color *= mix(0.72, 1.0, clamp(up * 1.35 + 0.20, 0.0, 1.0));
|
||||
|
||||
// Cloud banding, sampled on the sphere rather than in world XZ.
|
||||
float cloudField = fbm(sphereCoords(radial) * 22.0 + ubo.cloudWind.xy * time * 0.012);
|
||||
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
|
||||
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
|
||||
|
||||
// Reinhard keeps mid-tones saturated instead of clipping to white.
|
||||
color = color / (1.0 + color * 1.05);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
|
||||
// No distance fog: a planet seen from orbit has no haze band to fade into, and
|
||||
// the planar `smoothstep(240, 520, viewDepth)` would erase the whole globe. A
|
||||
// limb glow takes its place so the silhouette separates from the starfield.
|
||||
float rim = pow(1.0 - clamp(ndv, 0.0, 1.0), 3.0);
|
||||
color += vec3(0.10, 0.22, 0.44) * rim * 0.40;
|
||||
|
||||
outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#version 450
|
||||
|
||||
// Ocean surface of the spherical hex map (examples/hex-planet).
|
||||
//
|
||||
// Shares the planar water contract for its texture coordinates -- `vUV.x` is the
|
||||
// shore parameter, 0 in open ocean and 1 where the water ends against land -- but
|
||||
// takes its wave coordinates from the sphere rather than from world XZ, for the
|
||||
// same reason the terrain shader does: XZ collapses at the poles.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float s = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
|
||||
return s;
|
||||
}
|
||||
|
||||
vec2 sphereCoords(vec3 dir) {
|
||||
vec3 a = abs(dir);
|
||||
if (a.y >= a.x && a.y >= a.z) return dir.xz;
|
||||
if (a.x >= a.z) return dir.yz;
|
||||
return dir.xy;
|
||||
}
|
||||
|
||||
/** Inverts the host's second tonemap stage; see the terrain shader for the measurement. */
|
||||
vec3 hostTonemapInverse(vec3 srgb) {
|
||||
srgb = clamp(srgb, vec3(0.0), vec3(0.95));
|
||||
return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001));
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = ubo.cloud.z;
|
||||
vec3 radial = normalize(vWorldPos);
|
||||
vec2 p = sphereCoords(radial) * 26.0;
|
||||
|
||||
// Two travelling wave trains plus fbm chop: a flat normal makes an ocean read as
|
||||
// a painted disc from orbit, where the only clue to liquid is the specular lobe.
|
||||
float wave = sin(p.x * 0.55 + p.y * 0.35 - time * 1.05) * 0.45
|
||||
+ sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28
|
||||
+ fbm(p * 1.4 + time * 0.05) * 0.32;
|
||||
|
||||
float shore = clamp(vUV.x, 0.0, 1.0);
|
||||
vec3 deep = vec3(0.012, 0.045, 0.095);
|
||||
vec3 shallow = vec3(0.040, 0.170, 0.240);
|
||||
vec3 albedo = mix(deep, shallow, shore * 0.85);
|
||||
|
||||
// Sea ice. Without it the polar oceans stay dark navy under a grazing light angle
|
||||
// and the caps read as holes rather than ice.
|
||||
float ice = smoothstep(0.80, 0.94, abs(radial.y));
|
||||
albedo = mix(albedo, vec3(0.74, 0.80, 0.87), ice);
|
||||
|
||||
vec3 N = normalize(vNormal + vec3(dFdx(wave), 0.0, dFdy(wave)) * 0.55 + radial * wave * 0.035);
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
float ndh = max(dot(N, H), 0.0);
|
||||
|
||||
// Tight, strong highlight: the sun glint is what sells the surface as water.
|
||||
float shininess = mix(64.0, 900.0, 1.0 - fbm(p * 0.6));
|
||||
float spec = pow(ndh, shininess) * 0.40;
|
||||
vec3 F = vec3(0.02) + vec3(0.98) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
|
||||
|
||||
vec3 color = albedo * (ubo.ambient.rgb * 0.55 + ubo.lightColor.rgb * ndl * 0.72);
|
||||
color += F * spec * ubo.lightColor.rgb * 3.0;
|
||||
|
||||
// Fresnel on the limb: shallow grazing angles turn the ocean into a mirror of the
|
||||
// sky, which is also what keeps the far hemisphere from reading as flat navy.
|
||||
float fresnel = pow(1.0 - clamp(ndv, 0.0, 1.0), 4.0);
|
||||
color = mix(color, vec3(0.22, 0.42, 0.68), fresnel * 0.70);
|
||||
|
||||
color = color / (1.0 + color * 1.05);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
|
||||
outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
# Hex Map 3D
|
||||
|
||||
An interactive pointy-top 3D hex map: elevation terraces and cliffs, water,
|
||||
rivers, roads, walls and decorations, units with A* pathfinding, fog of war,
|
||||
procedural map generation, save/load, an orbit camera, mouse picking and a
|
||||
hex-shaped brush. It is a port of the Catlike Coding
|
||||
[Hex Map](https://catlikecoding.com/unity/hex-map/) reference project onto
|
||||
EVEngine's `hexmap` module.
|
||||
|
||||
This directory is the module's interactive reference example. The separate
|
||||
[`hex-terrain`](../hex-terrain/README.md) example is unrelated: it is the
|
||||
asset-free `mesh.hexterrain` procgen recipe and it still ships unchanged.
|
||||
|
||||
Everything geometric and everything the fog/units/pathfinding rules decide lives
|
||||
in C++ (`src/modules/hexmap`): the editable cell grid, its 5x5 chunk partition,
|
||||
the picking query, the per-chunk mesh builders (terrain, water, river, road, the
|
||||
fog overlay, walls and decorations), the search/visibility scratch, the unit
|
||||
registry, the save-payload codec and the procedural map generator. This example
|
||||
owns the camera, the input mapping, the brush tool, the per-chunk renderables, the
|
||||
unit markers and the HUD.
|
||||
|
||||
Run with `make run/win32-debug GAME=examples/hex-terrain-3d` (Linux:
|
||||
`make run/linux-debug GAME=examples/hex-terrain-3d`).
|
||||
|
||||
## Controls
|
||||
|
||||
| Key / input | Action |
|
||||
|---|---|
|
||||
| `W` `A` `S` `D` (or arrows) | Pan the camera on the XZ plane |
|
||||
| `Q` / `E` | Rotate the camera |
|
||||
| Mouse wheel (or `Z` / `X`) | Zoom; the far end tracks the map extent |
|
||||
| `1` .. `5` | Edit mode: Elevation, Water, Terrain, River, Road |
|
||||
| `[` / `]` | Decrease / increase the hex brush radius (`0`..`6`) |
|
||||
| `,` / `.` | Cycle the terrain palette (Sand, Grass, Mud, Stone, Snow) |
|
||||
| Left mouse | Raise / paint / start a river or road drag; plan a unit path |
|
||||
| Right mouse | Lower / erase (removes terrain paint, rivers or roads) |
|
||||
| `R` | New deterministic seed and rebuild the starter map |
|
||||
| `F` | Cycle the map size (20, 30, 40) |
|
||||
| `M` | Generate a procedural map (fresh seed) at the size `F` selected |
|
||||
| `N` | Mark every chunk dirty and rebuild |
|
||||
| `tab` | Select the next unit |
|
||||
| `G` | Plan a path from the selected unit to the hovered cell |
|
||||
| `space` | Send the selected unit along the planned path |
|
||||
| `escape` | Clear the planned path |
|
||||
| `T` / `Y` | Add a unit on the hovered cell / remove the selected unit |
|
||||
| `J` / `K` | Toggle the hovered cell's *explored* / *explorable* flag |
|
||||
| `F5` / `F9` | Save / load the map, its units and its explored flags |
|
||||
|
||||
Rivers and roads are painted by dragging from a cell to its neighbour, matching
|
||||
the reference editor. The module rejects uphill rivers, roads that climb more
|
||||
than one elevation step, and roads that cross a river mid-channel.
|
||||
|
||||
## What is implemented
|
||||
|
||||
- Pointy-top hex grid with the reference metrics (`outerRadius 10`,
|
||||
`elevationStep 3`, `solidFactor 0.8`, `waterFactor 0.6`, two terraces per
|
||||
slope, `chunkSize 5x5`).
|
||||
- Ground fans, blend strips between neighbours, terraced slopes and cliffs, and
|
||||
the corner matrix that closes the triple junctions (7 cases).
|
||||
- Water surface with a shore band, river channels carved to the stream bed, and
|
||||
road ribbons. The water is triangulated per direction like the reference: an
|
||||
open-water wedge plus a bridge quad towards flooded neighbours, or a subdivided
|
||||
four-triangle shore wedge plus a four-quad bank strip towards land — and that
|
||||
strip stays **flat at the water height**, borrowing only the neighbour's solid
|
||||
corner positions. Tilting it up to the bank reads as a bright band tracing every
|
||||
hexagon edge, which is why the whole surface is now provably horizontal.
|
||||
- Deterministic per-cell XZ and height noise, so no texture assets are required.
|
||||
- Five terrain layers blended per vertex and shaded with GGX lighting, slope
|
||||
rock, a snow line and moving cloud shade.
|
||||
- Fog of war: cells are counted per viewer, the first viewer latches a cell
|
||||
*explored* one-way, and the overlay draws a hexagonal column over every cell
|
||||
no viewer currently sees (dim for remembered ground, near black for ground
|
||||
never seen). The column also carries a skirt so the cliff faces along a fog
|
||||
boundary are covered.
|
||||
- Units on a hex grid: A* over the cell graph with roads, slopes, cliffs, walls,
|
||||
water and other units as movers, a turn budget derived from the unit's speed,
|
||||
and a Bezier walk whose vision follows the corridor it travels.
|
||||
- Save/load as a self-describing little-endian payload (grid + explored flags +
|
||||
units) written through `eve.Filesystem()`; the payload is validated in full
|
||||
before any observable state changes.
|
||||
- City/farm walls, wall towers and bridges generated as geometry (the reference
|
||||
instantiates prefabs; EVEngine ships none). Walls follow the reference's
|
||||
`wallLerp`/`wallThicknessOffset` construction, open a gate where a road crosses
|
||||
the boundary, and fill the high side of a step with a wedge.
|
||||
- Urban, farm, plant and special decorations, picked exactly like the reference:
|
||||
the three collections are each tested against a deterministic per-cell hash and
|
||||
the lowest passing hash wins, with a special feature suppressing the ordinary
|
||||
decoration of its cell.
|
||||
- A procedural map generator ported from the reference `MapGenerator`: seeded land
|
||||
regions grown with jitter and occasional rises/sinks, coastal erosion towards a
|
||||
land-percentage target, downhill rivers with lakes, a 40-cycle downwind moisture
|
||||
transport, and the reference's temperature/moisture biome table with plant
|
||||
levels. Generation is reproducible from a seed alone.
|
||||
- Orbit camera, ray picking with an iterative elevation refinement, a hex cursor
|
||||
and an immediate-mode HUD.
|
||||
|
||||
The example seeds a deterministic starter map (a two-step central plateau, a
|
||||
flooded basin, two rivers draining off the plateau, a road crossing the lowland
|
||||
and a walled district with gates where the road crosses its wall) plus three
|
||||
units, so every system is visible on the first frame. `M` replaces it with a
|
||||
procedurally generated map.
|
||||
|
||||
## Camera
|
||||
|
||||
`eve.Camera3D()` defaults to a `0.1 .. 100` clip range. That is smaller than a
|
||||
single map span once the grid passes a few chunks, so anything beyond 100 world
|
||||
units was silently clipped and the far half of the zoom range showed an empty
|
||||
frame. The example now derives the far plane from the map extent every frame and
|
||||
scales the far zoom distance with it (`ZOOM_FAR_MARGIN`), so the whole map always
|
||||
fits the view whatever the map size.
|
||||
|
||||
## Shaders
|
||||
|
||||
The five fragment shaders are shipped as SPIR-V next to their GLSL sources
|
||||
because runtime GLSL compilation is unavailable on Windows. The engine's
|
||||
built-in Mesh3D vertex stage already produces the varyings they consume, so no
|
||||
vertex stage is shipped. Regenerate them after editing a `.frag`:
|
||||
|
||||
```sh
|
||||
glslc -fshader-stage=frag shaders/hex_map_terrain.frag -o shaders/hex_map_terrain.frag.spv
|
||||
glslc -fshader-stage=frag shaders/hex_map_water.frag -o shaders/hex_map_water.frag.spv
|
||||
glslc -fshader-stage=frag shaders/hex_map_river.frag -o shaders/hex_map_river.frag.spv
|
||||
glslc -fshader-stage=frag shaders/hex_map_road.frag -o shaders/hex_map_road.frag.spv
|
||||
glslc -fshader-stage=frag shaders/hex_map_fog.frag -o shaders/hex_map_fog.frag.spv
|
||||
glslc -fshader-stage=frag shaders/hex_map_feature.frag -o shaders/hex_map_feature.frag.spv
|
||||
```
|
||||
|
||||
There are six stages but seven surface streams: `hex_map_feature` serves both the
|
||||
wall stream and the decoration stream, because a vertex's first texture coordinate
|
||||
already says which of the seven parts it belongs to
|
||||
(`0` wall, `1` tower, `2` bridge, `3` urban, `4` farm, `5` plant, `6` special).
|
||||
|
||||
The terrain fragment stage decodes three terrain layers from the two available
|
||||
texture coordinates (`u = layerA + layerB*8 + layerC*64`,
|
||||
`v = weightB + weightC*16`); `HexMapMesh.h` documents that encoding as a
|
||||
compatibility bridge, not as the terrain data format. The fog stage reads its
|
||||
single coordinate as the shade (`0` explored-but-unseen, `1` never explored).
|
||||
|
||||
## Not ported yet
|
||||
|
||||
Waterfalls and estuaries, the reference's wall-tower *prefab* proportions, and
|
||||
the fog overlay's per-cell visibility blend (it is a two-shade
|
||||
remembered/unseen pair). The map generator's erosion takes the lowest cell from
|
||||
the shared search frontier where the reference erodes a uniformly random cell,
|
||||
and its temperature jitter uses a private value noise instead of the reference's
|
||||
noise texture sample. `docs/usr/modules/hexmap.md` lists the same boundary from
|
||||
the module side.
|
||||
|
||||
`hex-map.png` is captured by the example itself after 90 frames.
|
||||
@@ -0,0 +1,9 @@
|
||||
// examples/hex-terrain-3d -- interactive hex map window configuration.
|
||||
// No `modules` key: the example runs against the full default module set.
|
||||
config = {
|
||||
width = 1280
|
||||
height = 800
|
||||
title = "EVEngine Hex Map"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
|
After Width: | Height: | Size: 488 KiB |
@@ -0,0 +1,118 @@
|
||||
#version 450
|
||||
|
||||
// Structures and decorations of the hex map: city/farm walls, wall towers,
|
||||
// bridges and the urban / farm / plant / special decorations.
|
||||
//
|
||||
// Two mesh streams feed this shader (`HexSurface::Wall` and
|
||||
// `HexSurface::Feature`); the mesh builders encode which part a vertex belongs
|
||||
// to in the first texture coordinate:
|
||||
//
|
||||
// 0 wall 1 tower 2 bridge
|
||||
// 3 urban 4 farm 5 plant 6 special
|
||||
//
|
||||
// Because the parts are drawn with different materials but share one pipeline,
|
||||
// the shader picks its albedo/roughness from that code rather than from a
|
||||
// texture, which keeps the example asset-free.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Codes are written as integer-valued floats; a half step snaps them back.
|
||||
int part = int(floor(vUV.x + 0.5));
|
||||
|
||||
vec3 albedo;
|
||||
float roughness;
|
||||
if (part == 0) {
|
||||
// Wall: dressed stone with a coursing pattern so the thin strips read.
|
||||
float course = step(0.5, fract(vWorldPos.y * 0.35));
|
||||
albedo = mix(vec3(0.46, 0.44, 0.40), vec3(0.58, 0.56, 0.51), course);
|
||||
roughness = 0.78;
|
||||
} else if (part == 1) {
|
||||
// Tower: slightly lighter stone, keeps the silhouette distinct.
|
||||
albedo = vec3(0.63, 0.61, 0.56);
|
||||
roughness = 0.70;
|
||||
} else if (part == 2) {
|
||||
// Bridge: timber planks across the channel.
|
||||
float plank = step(0.5, fract(vUV.y * 8.0));
|
||||
albedo = mix(vec3(0.36, 0.25, 0.15), vec3(0.46, 0.33, 0.20), plank);
|
||||
roughness = 0.85;
|
||||
} else if (part == 3) {
|
||||
// Urban: plastered walls and a tiled roof, split by height.
|
||||
albedo = vWorldPos.y > 0.0 ? vec3(0.72, 0.68, 0.60) : vec3(0.55, 0.30, 0.24);
|
||||
albedo = mix(vec3(0.55, 0.30, 0.24), vec3(0.78, 0.74, 0.66),
|
||||
step(0.5, fract(vWorldPos.y * 0.5 + 0.5)));
|
||||
roughness = 0.72;
|
||||
} else if (part == 4) {
|
||||
// Farm: tilled rows.
|
||||
float row = noise(vWorldPos.xz * 0.9);
|
||||
albedo = mix(vec3(0.52, 0.42, 0.22), vec3(0.66, 0.55, 0.30), row);
|
||||
roughness = 0.92;
|
||||
} else if (part == 5) {
|
||||
// Plant: foliage, darker at the base.
|
||||
float shade = clamp(vNormal.y * 0.4 + 0.6, 0.0, 1.0);
|
||||
albedo = mix(vec3(0.14, 0.30, 0.11), vec3(0.30, 0.52, 0.18), shade);
|
||||
roughness = 0.88;
|
||||
} else {
|
||||
// Special: pale monument stone.
|
||||
albedo = mix(vec3(0.62, 0.60, 0.55), vec3(0.80, 0.79, 0.75),
|
||||
step(0.5, fract(vWorldPos.y * 0.28)));
|
||||
roughness = 0.60;
|
||||
}
|
||||
|
||||
vec3 N = normalize(vNormal);
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
float ndh = max(dot(N, H), 0.0);
|
||||
|
||||
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
|
||||
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
|
||||
float k = pow(roughness + 1.0, 2.0) / 8.0;
|
||||
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
|
||||
vec3 F0 = vec3(0.04);
|
||||
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
|
||||
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
|
||||
|
||||
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.2;
|
||||
float sky = max(0.0, N.y) * 0.18 + 0.10;
|
||||
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.60 + vec3(0.10, 0.12, 0.15) * sky);
|
||||
|
||||
float cloudField = noise(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
|
||||
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
|
||||
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
|
||||
|
||||
color = color / (1.0 + color * 0.72);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#version 450
|
||||
|
||||
// Fog-of-war overlay of the hex map.
|
||||
//
|
||||
// The mesh builder (HexFogMesh.cpp) covers every cell that is not currently
|
||||
// fully visible with a hexagonal column and encodes the shade in the first
|
||||
// texture coordinate:
|
||||
// uv.x = 0 -> explored before, but no viewer sees it right now (dim)
|
||||
// uv.x = 1 -> never explored (opaque black)
|
||||
//
|
||||
// The shader outputs final display colours directly: the overlay is an
|
||||
// unlit UI-like surface, so the lit-surface tone mapping of the terrain shader
|
||||
// would darken it a second time.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float s = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
|
||||
return s;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float unknown = step(0.5, vUV.x);
|
||||
// The renderer tone-maps and gamma-encodes this surface like any other, so
|
||||
// these are *linear* values: a mid grey-blue for ground that was explored but
|
||||
// is not watched right now, and near black for ground never seen at all.
|
||||
vec3 seen = vec3(0.10, 0.13, 0.20);
|
||||
vec3 unseen = vec3(0.015, 0.02, 0.04);
|
||||
vec3 color = mix(seen, unseen, unknown);
|
||||
|
||||
// Large-scale mottling keeps the flat column tops from reading as plastic.
|
||||
float mottle = fbm(vWorldPos.xz * 0.06);
|
||||
color *= 0.86 + 0.28 * mottle * (1.0 - unknown * 0.6);
|
||||
|
||||
// A faint lift on the vertical walls so the fog column has a silhouette.
|
||||
float wall = 1.0 - abs(normalize(vNormal).y);
|
||||
color *= 1.0 - wall * 0.22;
|
||||
|
||||
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
|
||||
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
|
||||
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
|
||||
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#version 450
|
||||
|
||||
// River channel of the hex map. uv.x runs along the channel, uv.y across it.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = ubo.cloud.z;
|
||||
float along = vUV.x * 6.0;
|
||||
float across = clamp(vUV.y, 0.0, 1.0);
|
||||
vec2 p = vWorldPos.xz;
|
||||
|
||||
// Water flows downstream: ripples travel with +uv.x.
|
||||
float ripple =
|
||||
sin((p.x * 0.55 + p.y * 0.35) - time * 2.6) * 0.45 +
|
||||
sin((p.x * 1.30 - p.y * 0.90) - time * 4.1) * 0.25 +
|
||||
noise(p * 1.9 - vec2(time * 0.9, 0.0)) * 0.35;
|
||||
float bank = smoothstep(0.0, 0.35, across) * smoothstep(1.0, 0.65, across);
|
||||
vec3 N = normalize(vec3(dFdx(ripple), 1.0, dFdy(ripple)) * 1.8 + vNormal);
|
||||
N = normalize(mix(vNormal, N, 0.35 + 0.55 * bank));
|
||||
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
|
||||
vec3 albedo = mix(vec3(0.05, 0.20, 0.26), vec3(0.10, 0.40, 0.44), bank);
|
||||
float fresnel = 0.03 + 0.97 * pow(1.0 - ndv, 4.0);
|
||||
vec3 color = mix(albedo, vec3(0.32, 0.52, 0.62), fresnel * 0.55);
|
||||
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 72.0) * 0.70;
|
||||
color += albedo * ubo.ambient.rgb;
|
||||
|
||||
// Wet banks darken and desaturate towards the channel edges.
|
||||
color *= mix(0.72, 1.10, bank);
|
||||
color += vec3(0.55, 0.72, 0.70) * pow(max(dot(N, H), 0.0), 28.0) * (1.0 - bank) * 0.35;
|
||||
|
||||
color = 1.0 - exp(-color * 1.3);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#version 450
|
||||
|
||||
// Road ribbon of the hex map. uv.x runs along the road, uv.y across it.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = ubo.cloud.z;
|
||||
float across = clamp(vUV.y, 0.0, 1.0);
|
||||
vec2 p = vWorldPos.xz;
|
||||
float grain = noise(p * 3.4);
|
||||
|
||||
// Two wheel ruts with a lighter crown, like the reference road material.
|
||||
float ruts = smoothstep(0.10, 0.22, abs(across - 0.28)) * smoothstep(0.10, 0.22, abs(across - 0.72));
|
||||
vec3 albedo = mix(vec3(0.30, 0.25, 0.18), vec3(0.46, 0.40, 0.31), grain);
|
||||
albedo = mix(albedo * 0.72, albedo, ruts);
|
||||
albedo *= smoothstep(0.0, 0.14, across) * smoothstep(1.0, 0.86, across) * 0.35 + 0.65;
|
||||
|
||||
vec3 N = normalize(vNormal + vec3(dFdx(grain), 0.0, dFdy(grain)) * 0.10);
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
vec3 color = albedo * (ubo.ambient.rgb + ubo.lightColor.rgb * ndl * 1.35);
|
||||
color *= mix(0.80, 1.0, clamp(N.y * 1.2 + 0.2, 0.0, 1.0));
|
||||
|
||||
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
|
||||
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
|
||||
color = 1.0 - exp(-color * 1.25);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#version 450
|
||||
|
||||
// Terrain surface of the hex map.
|
||||
//
|
||||
// The mesh builder packs three terrain layers into the two available texture
|
||||
// coordinates (see HexMapMesh.h):
|
||||
// uv.x = layerA + layerB * 8 + layerC * 64
|
||||
// uv.y = weightB + weightC * 16
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
float fbm(vec2 p) {
|
||||
float s = 0.0, a = 0.5;
|
||||
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
|
||||
return s;
|
||||
}
|
||||
|
||||
// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow.
|
||||
vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) {
|
||||
if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain);
|
||||
if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3);
|
||||
if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain);
|
||||
if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain);
|
||||
return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35);
|
||||
}
|
||||
float terrainRoughness(int id) {
|
||||
if (id == 0) return 0.92;
|
||||
if (id == 1) return 0.88;
|
||||
if (id == 2) return 0.74;
|
||||
if (id == 3) return 0.68;
|
||||
return 0.42;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float packedIndices = vUV.x;
|
||||
float packedWeights = vUV.y;
|
||||
int layerA = int(mod(packedIndices, 8.0) + 0.5);
|
||||
int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5);
|
||||
int layerC = int(floor(packedIndices / 64.0) + 0.5);
|
||||
float weightB = fract(packedWeights);
|
||||
float weightC = floor(packedWeights) / 16.0;
|
||||
float weightA = max(0.0, 1.0 - weightB - weightC);
|
||||
|
||||
float time = ubo.cloud.z;
|
||||
vec2 p = vWorldPos.xz * 0.55;
|
||||
float grain = noise(p * 9.0);
|
||||
float patchiness = fbm(p * 1.6);
|
||||
|
||||
vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA
|
||||
+ terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB
|
||||
+ terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC;
|
||||
float roughness = terrainRoughness(layerA) * weightA
|
||||
+ terrainRoughness(layerB) * weightB
|
||||
+ terrainRoughness(layerC) * weightC;
|
||||
|
||||
// Snow line and rock reach on high ground, plus large-scale colour breakup.
|
||||
float altitude = vWorldPos.y;
|
||||
float snow = smoothstep(15.0, 21.0, altitude + fbm(p * 2.3) * 2.2);
|
||||
float rock = smoothstep(0.55, 0.95, 1.0 - abs(vNormal.y));
|
||||
albedo = mix(albedo, vec3(0.42, 0.40, 0.37), rock * 0.55);
|
||||
albedo = mix(albedo, vec3(0.94, 0.96, 0.99), snow * 0.85);
|
||||
roughness = mix(roughness, 0.38, snow * 0.7);
|
||||
|
||||
float detail = fbm(p * 6.0);
|
||||
vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22);
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
float ndh = max(dot(N, H), 0.0);
|
||||
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
|
||||
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
|
||||
float k = pow(roughness + 1.0, 2.0) / 8.0;
|
||||
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
|
||||
vec3 F0 = vec3(0.04);
|
||||
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
|
||||
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
|
||||
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.6;
|
||||
float sky = max(0.0, N.y) * 0.16 + 0.06;
|
||||
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky);
|
||||
|
||||
// Slope shading so cliffs read as rock even without a texture.
|
||||
color *= mix(0.72, 1.0, clamp(N.y * 1.35 + 0.20, 0.0, 1.0));
|
||||
|
||||
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * time * 0.012);
|
||||
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
|
||||
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
|
||||
// Reinhard keeps mid-tones saturated instead of clipping to white.
|
||||
color = color / (1.0 + color * 0.72);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#version 450
|
||||
|
||||
// Water surface of the hex map. uv.x is the shore parameter emitted by the
|
||||
// water mesh builder: 0 in open water, approaching 1 on the shoreline band.
|
||||
|
||||
layout(location=0) in vec3 vNormal;
|
||||
layout(location=1) in vec2 vUV;
|
||||
layout(location=2) in vec4 vTint;
|
||||
layout(location=3) in vec3 vWorldPos;
|
||||
layout(location=4) in vec3 vCameraPos;
|
||||
layout(location=5) in vec3 vViewPos;
|
||||
|
||||
struct Light3D { vec4 posRadius; vec4 color; };
|
||||
layout(set=0,binding=0,std140) uniform Frame {
|
||||
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
|
||||
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
|
||||
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
|
||||
} ubo;
|
||||
layout(location=0) out vec4 outColor;
|
||||
|
||||
const float PI = 3.14159265359;
|
||||
|
||||
float hash21(vec2 p) {
|
||||
p = fract(p * vec2(123.34, 456.21));
|
||||
p += dot(p, p + 45.32);
|
||||
return fract(p.x * p.y);
|
||||
}
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
|
||||
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float time = ubo.cloud.z;
|
||||
float shore = clamp(vUV.x, 0.0, 1.0);
|
||||
vec2 p = vWorldPos.xz;
|
||||
|
||||
// Two crossing wave trains plus fine chop; the shoreline gets shorter waves.
|
||||
float wave =
|
||||
sin(p.x * 0.42 + time * 1.35) * 0.55 +
|
||||
sin(p.y * 0.36 - time * 1.05) * 0.45 +
|
||||
sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28 +
|
||||
noise(p * 0.9 + time * 0.25) * 0.35;
|
||||
float slope = shore * 0.35 + 0.06;
|
||||
vec3 N = normalize(vec3(dFdx(wave) * 1.6, 1.0, dFdy(wave) * 1.6) + vNormal * slope);
|
||||
N = normalize(mix(N, vNormal, shore * 0.4));
|
||||
|
||||
vec3 V = normalize(vCameraPos - vWorldPos);
|
||||
vec3 L = normalize(ubo.lightDirIntensity.xyz);
|
||||
vec3 H = normalize(V + L);
|
||||
float ndl = max(dot(N, L), 0.0);
|
||||
float ndv = max(dot(N, V), 0.001);
|
||||
|
||||
// Depth proxy: open water is deep, the shore band is shallow.
|
||||
float depth = 1.0 - shore;
|
||||
vec3 deep = vec3(0.012, 0.055, 0.105);
|
||||
vec3 shallow = vec3(0.055, 0.30, 0.36);
|
||||
vec3 albedo = mix(shallow, deep, smoothstep(0.15, 0.85, depth));
|
||||
|
||||
float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
|
||||
vec3 sky = mix(vec3(0.10, 0.20, 0.28), vec3(0.42, 0.62, 0.78), max(N.y, 0.0));
|
||||
vec3 color = mix(albedo, sky, fresnel * 0.72);
|
||||
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 96.0) * 0.95;
|
||||
color += albedo * ndl * ubo.lightColor.rgb * 0.35;
|
||||
color += albedo * ubo.ambient.rgb;
|
||||
|
||||
// Foam rides the shoreline band and breaks up with noise.
|
||||
float foamNoise = noise(p * 1.35 + vec2(time * 0.20, -time * 0.16));
|
||||
float foam = smoothstep(0.55, 0.98, shore) * smoothstep(0.30, 0.75, foamNoise + shore * 0.35);
|
||||
foam += smoothstep(0.90, 1.0, shore) * 0.55;
|
||||
color = mix(color, vec3(0.90, 0.96, 0.98), clamp(foam, 0.0, 0.85));
|
||||
|
||||
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
|
||||
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
|
||||
color = 1.0 - exp(-color * 1.35);
|
||||
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
|
||||
float fog = smoothstep(240.0, 520.0, length(vViewPos));
|
||||
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
|
||||
outColor = vec4(color * vTint.rgb, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
config <- {
|
||||
width = 1100
|
||||
height = 720
|
||||
title = "EVEngine Hex Terrain"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
@@ -1,3 +1,7 @@
|
||||
window_title <- "EVEngine House Generator";
|
||||
window_width <- 960;
|
||||
window_height <- 540;
|
||||
config <- {
|
||||
width = 960
|
||||
height = 540
|
||||
title = "EVEngine House Generator"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
window_title <- "INK ARENA | Surface painting laboratory";
|
||||
window_width <- 1280;
|
||||
window_height <- 800;
|
||||
|
||||
config <- { width=1280 height=800 title="INK ARENA" hotReload=false };
|
||||
config <- {
|
||||
width = 1280
|
||||
height = 800
|
||||
title = "INK ARENA | Surface painting laboratory"
|
||||
hotReload = false
|
||||
};
|
||||
@@ -3,8 +3,8 @@
|
||||
// R = unlocked (1 clear / 0 fogged) — presentation coverage
|
||||
// G = selected cell blink
|
||||
// B = dissolve threshold while unlocking
|
||||
// Gameplay data unlocks immediately; the visual mask keeps R fogged until
|
||||
// dissolve finishes (so B can actually burn the cloud away).
|
||||
// Gameplay data unlocks immediately; presentation R advances with an eased
|
||||
// reveal while B contracts and fades whole cloud clusters.
|
||||
//
|
||||
// Controls:
|
||||
// LMB select a fogged cell
|
||||
@@ -29,7 +29,7 @@ persist mapW = 0.0
|
||||
persist mapH = 0.0
|
||||
persist cellW = 0.0
|
||||
persist cellH = 0.0
|
||||
persist fogAlpha = 0.90
|
||||
persist fogAlpha = 0.96
|
||||
persist prevLeft = false
|
||||
persist status = "LMB select · Space unlock · R reset"
|
||||
|
||||
@@ -45,9 +45,13 @@ function ensureGrid() {
|
||||
unlocked.push(false);
|
||||
dissolving.push(-1.0);
|
||||
}
|
||||
// Starting cleared patch so the dual-cloud sheet + soft hole are visible.
|
||||
for (local y = 6; y <= 9; ++y) {
|
||||
for (local x = 10; x <= 13; ++x) {
|
||||
// Start with a discovered western region so the important silhouette is a
|
||||
// long, irregular exploration front (matching an actual strategy map),
|
||||
// rather than a synthetic rounded rectangle punched through the fog.
|
||||
for (local y = 0; y < gridH; ++y) {
|
||||
local frontierX = 8 + ((y * 7 + 3) % 3);
|
||||
if (y == 3 || y == 4 || y == 11) frontierX++;
|
||||
for (local x = 0; x <= frontierX; ++x) {
|
||||
unlocked[cellIndex(x, y)] = true;
|
||||
}
|
||||
}
|
||||
@@ -62,21 +66,16 @@ function clearMask() {
|
||||
}
|
||||
|
||||
function softWeight(dist, radius) {
|
||||
// Hard core to ~60% radius, then smooth falloff — merges adjacent cells into one hole.
|
||||
// Firm core (clearCore stays solid) + wide soft penumbra for gradual rim.
|
||||
if (dist >= radius) return 0.0;
|
||||
local core = radius * 0.60;
|
||||
local core = radius * 0.50;
|
||||
if (dist <= core) return 1.0;
|
||||
local t = (radius - dist) / (radius - core);
|
||||
return t * t * (3.0 - 2.0 * t);
|
||||
}
|
||||
|
||||
function stampSoft(gx, gy, r, g, b, radiusScale) {
|
||||
// Local brush only (fast). Hard-core soft disc in mask-pixel space.
|
||||
local cellPx = maskW.tofloat() / gridW.tofloat();
|
||||
local cellPy = maskH.tofloat() / gridH.tofloat();
|
||||
local px = (gx.tofloat() + 0.5) * cellPx;
|
||||
local py = (gy.tofloat() + 0.5) * cellPy;
|
||||
local radius = radiusScale * cellPx;
|
||||
function stampSoftAt(px, py, r, g, b, radius) {
|
||||
// Local brush in mask-pixel space (fast).
|
||||
if (radius < 2.0) radius = 2.0;
|
||||
local x0 = (px - radius).tointeger();
|
||||
local y0 = (py - radius).tointeger();
|
||||
@@ -106,6 +105,13 @@ function stampSoft(gx, gy, r, g, b, radiusScale) {
|
||||
}
|
||||
}
|
||||
|
||||
function stampSoft(gx, gy, r, g, b, radiusScale) {
|
||||
local cellPx = maskW.tofloat() / gridW.tofloat();
|
||||
local px = (gx.tofloat() + 0.5) * cellPx;
|
||||
local py = (gy.tofloat() + 0.5) * (maskH.tofloat() / gridH.tofloat());
|
||||
stampSoftAt(px, py, r, g, b, radiusScale * cellPx);
|
||||
}
|
||||
|
||||
function rebuildMask() {
|
||||
ensureGrid();
|
||||
clearMask();
|
||||
@@ -114,12 +120,17 @@ function rebuildMask() {
|
||||
local idx = cellIndex(x, y);
|
||||
local sel = (x == selectedX && y == selectedY) ? 1.0 : 0.0;
|
||||
if (dissolving[idx] >= 0.0) {
|
||||
// Stay fogged visually (R=0) while B dissolves the cloud.
|
||||
stampSoft(x, y, 0.0, sel, dissolving[idx], 1.95);
|
||||
// Advance reveal and dissolve together. Keeping R at zero until
|
||||
// the last frame leaves an opaque sheet that vanishes abruptly.
|
||||
local t = dissolving[idx];
|
||||
local reveal = t * t * (3.0 - 2.0 * t);
|
||||
stampSoft(x, y, reveal, sel, t, 2.15);
|
||||
} else if (unlocked[idx]) {
|
||||
stampSoft(x, y, 1.0, sel, 0.0, 1.95);
|
||||
// Soft unlock disc — solid core clears the hole; soft ring
|
||||
// feeds the shader frontier (dense → clumps → islands).
|
||||
stampSoft(x, y, 1.0, sel, 0.0, 2.15);
|
||||
} else if (sel > 0.0) {
|
||||
stampSoft(x, y, 0.0, sel, 0.0, 0.95);
|
||||
stampSoft(x, y, 0.0, sel, 0.0, 0.90);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,18 +147,10 @@ function screenToCell(mx, my) {
|
||||
return [cx, cy];
|
||||
}
|
||||
|
||||
function terrainColor(x, y) {
|
||||
// Slightly richer underlay so the unlock hole reads against real map tones.
|
||||
local h = ((x * 13 + y * 7) % 5).tofloat() / 5.0;
|
||||
local road = ((x + y * 3) % 17 == 0) || ((x * 2 + y) % 19 == 0);
|
||||
if (road) return [0.62, 0.58, 0.42];
|
||||
if ((x + y) % 7 == 0) return [0.42, 0.72, 0.38];
|
||||
if ((x * 3 + y) % 11 == 0) return [0.78, 0.68, 0.34];
|
||||
return [0.38 + h * 0.22, 0.58 + h * 0.20, 0.30 + h * 0.12];
|
||||
}
|
||||
|
||||
eve_init = function() {
|
||||
gfx.setBackgroundColor(0.05, 0.07, 0.10, 1.0);
|
||||
// Neutral stand-in only: MapFog remains an overlay and does not own the
|
||||
// revealed scene. Avoid a black clear color that reads as a second mask.
|
||||
gfx.setBackgroundColor(0.55, 0.60, 0.66, 1.0);
|
||||
mapW = (config.width - 80).tofloat();
|
||||
mapH = (config.height - 120).tofloat();
|
||||
cellW = mapW / gridW.tofloat();
|
||||
@@ -162,21 +165,26 @@ eve_init = function() {
|
||||
fog = gfx.newMapFog();
|
||||
fog.setCloudTexture(fog.makeCloudTexture(512));
|
||||
fog.setMaskTexture(maskTex);
|
||||
// Distinct dual-layer tiling/speed (article): large soft billows, living scroll.
|
||||
fog.setCloudTiling(0.55, 0.95);
|
||||
fog.setCloudSpeed(0.008, 0.015);
|
||||
fog.setCloudMix(0.35);
|
||||
fog.setDistort(0.22);
|
||||
fog.setDistortFix(-0.012, 0.008);
|
||||
fog.setFogColor(0.92, 0.94, 0.98);
|
||||
// Large cotton puffs + dual reverse scroll (reference FoW billows).
|
||||
// More than one repeat is essential: sub-1.0 tiling magnifies one texture
|
||||
// quadrant into the broad defocused blobs seen in the old result.
|
||||
fog.setCloudTiling(1.10, 1.65);
|
||||
fog.setCloudSpeed(0.010, 0.016);
|
||||
fog.setCloudMix(0.30);
|
||||
fog.setDistort(0.085);
|
||||
fog.setDistortFix(-0.006, 0.004);
|
||||
fog.setFogColor(0.98, 0.99, 1.00);
|
||||
fog.setFogAlpha(fogAlpha);
|
||||
// Soft-stamped mask + UV warp => wispy cloudy unlock rim.
|
||||
fog.setEdgeSoftness(0.14);
|
||||
fog.setShadow(0.034, 0.048, 0.78);
|
||||
// Soft approach for gradual rim sparseness; clearCore keeps hole clean.
|
||||
fog.setEdgeSoftness(0.16);
|
||||
// The reference shapes volume inside each cloud; an offset ground shadow
|
||||
// creates a second clipped silhouette and makes intact puffs look bitten.
|
||||
fog.setShadowEnabled(false);
|
||||
fog.setSelectStrength(0.90);
|
||||
fog.setDissolveScale(1.5);
|
||||
// Thickness from lit cloud luminance; mask still owns the hole.
|
||||
fog.setCloudDensity(0.42, 0.16);
|
||||
// Dense deep sheet; frontier raises cover gate → sparse islands at rim.
|
||||
fog.setCloudDensity(0.24, 0.10);
|
||||
// Wider soft frontier so density thins gradually into islands.
|
||||
rebuildMask();
|
||||
print("Map fog: LMB select, Space unlock, R reset, [/] opacity\n");
|
||||
};
|
||||
@@ -232,7 +240,7 @@ eve_update = function(dt) {
|
||||
local dirty = false;
|
||||
for (local i = 0; i < dissolving.len(); ++i) {
|
||||
if (dissolving[i] < 0.0) continue;
|
||||
dissolving[i] = dissolving[i] + dt * 0.65;
|
||||
dissolving[i] = dissolving[i] + dt * 0.32;
|
||||
if (dissolving[i] >= 1.0) dissolving[i] = -1.0;
|
||||
dirty = true;
|
||||
}
|
||||
@@ -241,15 +249,7 @@ eve_update = function(dt) {
|
||||
|
||||
eve_render = function() {
|
||||
gfx.clear();
|
||||
|
||||
gfx.drawSolidRect(mapX, mapY, mapW, mapH, 0.18, 0.28, 0.16, 1.0);
|
||||
for (local y = 0; y < gridH; ++y) {
|
||||
for (local x = 0; x < gridW; ++x) {
|
||||
local c = terrainColor(x, y);
|
||||
gfx.drawSolidRect(mapX + x.tofloat() * cellW, mapY + y.tofloat() * cellH,
|
||||
cellW, cellH, c[0], c[1], c[2], 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// MapFog is overlay-only. A real game draws its map/scene before this
|
||||
// call; the example deliberately does not invent a green "revealed" layer.
|
||||
fog.draw(mapX, mapY, mapW, mapH);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
# Material Editor
|
||||
|
||||
Project-composed material studio: a UE5-style Material Sphere sits in the center
|
||||
viewport while the right inspector edits a live `gfx.Material`. There is no fixed
|
||||
C++ material-editor window — the Squirrel host owns layout, widgets, and preview.
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
# Linux (headless Cloud VM)
|
||||
export VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json
|
||||
export ALSOFT_DRIVERS=null
|
||||
export XDG_RUNTIME_DIR=/tmp/xdg-runtime && mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
|
||||
cd examples/material-editor && xvfb-run -a ../../build/linux-debug/src/engine/eve run
|
||||
|
||||
# Windows
|
||||
make run/win32-debug GAME=examples/material-editor
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
- **Top** — material presets (Plastic / Rubber / Brushed / Chrome / Gold / Copper / Glass) and turntable pause
|
||||
- **Center** — embedded 3D viewport with the UE5 Material Sphere (`gfx.newMeshSphere(64, 32)`), studio IBL cubemap (so metallic presets stay readable), key/fill/rim lights, and a neutral shadow floor
|
||||
- **Right** — schema-aligned knobs mirroring `MaterialDocumentTarget`: shading model, tint, metallic, roughness, surface/blend mode, alpha cutoff, parallax, lighting and shadow flags, plus preview mesh switch (Sphere / Cube / Cylinder)
|
||||
|
||||
## Controls
|
||||
|
||||
- LMB drag in the viewport to orbit
|
||||
- Mouse wheel to zoom
|
||||
- Auto turntable (Pause spin in the toolbar)
|
||||
- Inspector edits apply immediately to the shared `Material` on the preview ball
|
||||
|
||||
Metallic / chrome / gold need image-based lighting: ambient alone is scaled by `(1 - metallic)`. The example bakes a six-face studio sky via `gfx.newReflectionProbeCapture` and attaches it with `camera.setEnvMap`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `config.nut` | Window title / size / hot reload |
|
||||
| `main.nut` | Workspace panels, UE5 sphere preview, live Material binding |
|
||||
| `material-editor.png` | Auto-captured frame after a few frames (`gfx.saveFramePng`) |
|
||||
|
||||
Knobs write one authoritative `Material` attached via `Renderable3D.setMaterial`; legacy tint/metallic/roughness fields stay synchronized for paths that still read MeshRenderer state.
|
||||
@@ -0,0 +1,7 @@
|
||||
config = {
|
||||
title = "EVEngine Material Editor",
|
||||
width = 1280,
|
||||
height = 720,
|
||||
debug = true,
|
||||
hotReload = true,
|
||||
};
|
||||
@@ -0,0 +1,464 @@
|
||||
// Project-composed material editor. Center viewport shows a UE5-style material
|
||||
// sphere; the right inspector writes live Material knobs (no C++ material window).
|
||||
|
||||
persist matEd = {
|
||||
workspace = null,
|
||||
camera = null,
|
||||
ball = null,
|
||||
ground = null,
|
||||
material = null,
|
||||
probe = null,
|
||||
keyLight = null,
|
||||
fillLight = null,
|
||||
rimLight = null,
|
||||
meshKind = 0, // 0 sphere, 1 cube, 2 cylinder
|
||||
shadingModel = 0, // pbr, unlit, hair
|
||||
surfaceMode = 0, // opaque, masked, transparent
|
||||
blendMode = 0, // alpha, premultiplied, additive, multiply
|
||||
tintR = 0.82, tintG = 0.82, tintB = 0.84, tintA = 1.0,
|
||||
metallic = 0.0,
|
||||
roughness = 0.45,
|
||||
alphaCutoff = 0.5,
|
||||
parallax = 0.0,
|
||||
receiveLight = true,
|
||||
castShadow = true,
|
||||
receiveShadow = true,
|
||||
doubleSided = false,
|
||||
depthWrite = true,
|
||||
autoSpin = true,
|
||||
yaw = 0.35,
|
||||
orbitYaw = 0.55,
|
||||
orbitPitch = 0.38,
|
||||
orbitDist = 4.6,
|
||||
orbiting = false,
|
||||
lastX = 0.0,
|
||||
lastY = 0.0,
|
||||
status = "UE5 material sphere · edit params on the right",
|
||||
frame = 0,
|
||||
screenshotSaved = false,
|
||||
};
|
||||
|
||||
SHADING_MODELS <- ["pbr", "unlit", "hair"];
|
||||
SURFACE_MODES <- ["opaque", "masked", "transparent"];
|
||||
BLEND_MODES <- ["alpha", "premultiplied", "additive", "multiply"];
|
||||
MESH_KINDS <- ["sphere", "cube", "cylinder"];
|
||||
|
||||
presets <- {
|
||||
plastic = { r=0.82, g=0.82, b=0.84, a=1.0, metallic=0.0, roughness=0.45 },
|
||||
rubber = { r=0.12, g=0.12, b=0.13, a=1.0, metallic=0.0, roughness=0.92 },
|
||||
brushed = { r=0.72, g=0.74, b=0.78, a=1.0, metallic=0.88, roughness=0.42 },
|
||||
chrome = { r=0.95, g=0.96, b=0.98, a=1.0, metallic=1.0, roughness=0.08 },
|
||||
gold = { r=1.0, g=0.72, b=0.22, a=1.0, metallic=1.0, roughness=0.22 },
|
||||
copper = { r=0.95, g=0.52, b=0.28, a=1.0, metallic=1.0, roughness=0.28 },
|
||||
glass = { r=0.72, g=0.88, b=0.95, a=0.35, metallic=0.0, roughness=0.06 },
|
||||
};
|
||||
|
||||
function clamp(v, lo, hi) {
|
||||
if (v < lo) return lo;
|
||||
if (v > hi) return hi;
|
||||
return v;
|
||||
}
|
||||
|
||||
function eventParts(path) {
|
||||
local slash = path.find("/");
|
||||
return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)];
|
||||
}
|
||||
|
||||
function previewMesh() {
|
||||
if (matEd.meshKind == 1) return gfx.newMeshCube(1.0);
|
||||
if (matEd.meshKind == 2) return gfx.newMeshCylinder(48, 1, true);
|
||||
// High-tessellation UV sphere mirrors the UE5 Material Editor default ball.
|
||||
return gfx.newMeshSphere(64, 32);
|
||||
}
|
||||
|
||||
function applyMaterial() {
|
||||
if (matEd.material == null) return;
|
||||
local model = SHADING_MODELS[matEd.shadingModel];
|
||||
local surface = SURFACE_MODES[matEd.surfaceMode];
|
||||
local blend = BLEND_MODES[matEd.blendMode];
|
||||
matEd.material.setShadingModel(model);
|
||||
matEd.material.setTint(matEd.tintR, matEd.tintG, matEd.tintB, matEd.tintA);
|
||||
matEd.material.setMetallic(matEd.metallic);
|
||||
matEd.material.setRoughness(matEd.roughness);
|
||||
matEd.material.setSurfaceMode(surface);
|
||||
matEd.material.setBlendMode(blend);
|
||||
matEd.material.setAlphaCutoff(matEd.alphaCutoff);
|
||||
matEd.material.setParallax(matEd.parallax, 8.0, 24.0);
|
||||
matEd.material.setReceiveLight(matEd.receiveLight);
|
||||
matEd.material.setCastShadow(matEd.castShadow);
|
||||
matEd.material.setReceiveShadow(matEd.receiveShadow);
|
||||
matEd.material.setDoubleSided(matEd.doubleSided);
|
||||
matEd.material.setDepthWrite(matEd.depthWrite);
|
||||
if (matEd.ball != null) {
|
||||
matEd.ball.setMaterial(matEd.material);
|
||||
// Keep legacy MeshRenderer fields in sync for paths that still read them.
|
||||
matEd.ball.setTint(matEd.tintR, matEd.tintG, matEd.tintB, matEd.tintA);
|
||||
matEd.ball.setMetallic(matEd.metallic);
|
||||
matEd.ball.setRoughness(matEd.roughness);
|
||||
matEd.ball.setCastShadow(matEd.castShadow);
|
||||
matEd.ball.setReceiveShadow(matEd.receiveShadow);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreset(name) {
|
||||
if (!(name in presets)) return;
|
||||
local p = presets[name];
|
||||
matEd.tintR = p.r; matEd.tintG = p.g; matEd.tintB = p.b; matEd.tintA = p.a;
|
||||
matEd.metallic = p.metallic;
|
||||
matEd.roughness = p.roughness;
|
||||
matEd.shadingModel = 0;
|
||||
if (name == "glass") {
|
||||
matEd.surfaceMode = 2;
|
||||
matEd.blendMode = 0;
|
||||
matEd.depthWrite = false;
|
||||
} else {
|
||||
matEd.surfaceMode = 0;
|
||||
matEd.blendMode = 0;
|
||||
matEd.depthWrite = true;
|
||||
}
|
||||
applyMaterial();
|
||||
syncInspectorWidgets();
|
||||
matEd.status = "Preset · " + name;
|
||||
}
|
||||
|
||||
function rebuildPreviewMesh() {
|
||||
if (matEd.ball == null) matEd.ball = eve.Renderable3D();
|
||||
matEd.ball.setMesh(previewMesh());
|
||||
if (matEd.meshKind == 0) {
|
||||
matEd.ball.setScale(1.35, 1.35, 1.35);
|
||||
matEd.ball.setPosition(0.0, 0.15, 0.0);
|
||||
} else if (matEd.meshKind == 1) {
|
||||
matEd.ball.setScale(1.55, 1.55, 1.55);
|
||||
matEd.ball.setPosition(0.0, 0.05, 0.0);
|
||||
} else {
|
||||
matEd.ball.setScale(1.15, 1.7, 1.15);
|
||||
matEd.ball.setPosition(0.0, 0.05, 0.0);
|
||||
}
|
||||
matEd.ball.setYaw(matEd.yaw);
|
||||
applyMaterial();
|
||||
matEd.status = "Preview mesh · " + MESH_KINDS[matEd.meshKind];
|
||||
}
|
||||
|
||||
function updateOrbitCamera() {
|
||||
local cy = cos(matEd.orbitYaw);
|
||||
local sy = sin(matEd.orbitYaw);
|
||||
local cp = cos(matEd.orbitPitch);
|
||||
local sp = sin(matEd.orbitPitch);
|
||||
local x = matEd.orbitDist * cp * sy;
|
||||
local y = matEd.orbitDist * sp + 0.2;
|
||||
local z = matEd.orbitDist * cp * cy;
|
||||
matEd.camera.setEye(x, y, z);
|
||||
matEd.camera.setTarget(0.0, 0.1, 0.0);
|
||||
matEd.camera.setUp(0.0, 1.0, 0.0);
|
||||
}
|
||||
|
||||
function configureWorkspace() {
|
||||
matEd.workspace = editor.newWorkspace("material.studio", "Material Studio");
|
||||
matEd.workspace.setRegionSize("top", 46.0);
|
||||
matEd.workspace.setRegionSize("right", 320.0);
|
||||
matEd.workspace.setRegionSize("left", 0.0);
|
||||
matEd.workspace.setRegionSize("bottom", 0.0);
|
||||
matEd.workspace.layout(config.width.tofloat(), config.height.tofloat());
|
||||
matEd.workspace.registerPanel("toolbar", "Material Studio", "top", 0);
|
||||
matEd.workspace.registerPanel("preview", "Material Sphere", "center", 10);
|
||||
matEd.workspace.registerPanel("inspector", "Parameters", "right", 20);
|
||||
matEd.workspace.setPanelCapability("preview", "scene.viewport.3d");
|
||||
matEd.workspace.setPanelCapability("inspector", "property.material");
|
||||
}
|
||||
|
||||
function panelToolbar() {
|
||||
ui.beginRow("toolbar-row", 8.0);
|
||||
ui.button("Plastic", "preset-plastic");
|
||||
ui.button("Rubber", "preset-rubber");
|
||||
ui.button("Brushed", "preset-brushed");
|
||||
ui.button("Chrome", "preset-chrome");
|
||||
ui.button("Gold", "preset-gold");
|
||||
ui.button("Copper", "preset-copper");
|
||||
ui.button("Glass", "preset-glass");
|
||||
ui.separator("toolbar-sep");
|
||||
ui.button(matEd.autoSpin ? "Pause spin" : "Resume spin", "spin");
|
||||
ui.text("", "toolbar-status");
|
||||
ui.end();
|
||||
}
|
||||
|
||||
function panelPreview() {
|
||||
ui.text("UE5 Material Sphere · LMB orbit · wheel zoom · auto turntable", "preview-help");
|
||||
ui.viewport("mat-vp", matEd.workspace.getRegionW("center") - 20.0,
|
||||
matEd.workspace.getRegionH("center") - 58.0);
|
||||
ui.text("", "preview-status");
|
||||
}
|
||||
|
||||
function panelInspector() {
|
||||
ui.text("Shading", "shading-title");
|
||||
ui.combo("Model", "pbr\nunlit\nhair", matEd.shadingModel, "shading");
|
||||
ui.colorPalette("Tint", matEd.tintR, matEd.tintG, matEd.tintB, matEd.tintA, "tint");
|
||||
ui.slider("Metallic", matEd.metallic, 0.0, 1.0, "metallic");
|
||||
ui.slider("Roughness", matEd.roughness, 0.04, 1.0, "roughness");
|
||||
ui.separator("sep-surface");
|
||||
ui.text("Surface", "surface-title");
|
||||
ui.combo("Mode", "opaque\nmasked\ntransparent", matEd.surfaceMode, "surface");
|
||||
ui.combo("Blend", "alpha\npremultiplied\nadditive\nmultiply", matEd.blendMode, "blend");
|
||||
ui.slider("Alpha cutoff", matEd.alphaCutoff, 0.0, 1.0, "alpha-cutoff");
|
||||
ui.slider("Parallax", matEd.parallax, 0.0, 0.08, "parallax");
|
||||
ui.checkbox("Depth write", matEd.depthWrite, "depth-write");
|
||||
ui.checkbox("Double sided", matEd.doubleSided, "double-sided");
|
||||
ui.separator("sep-light");
|
||||
ui.text("Lighting / Shadow", "light-title");
|
||||
ui.checkbox("Receive light", matEd.receiveLight, "receive-light");
|
||||
ui.checkbox("Cast shadow", matEd.castShadow, "cast-shadow");
|
||||
ui.checkbox("Receive shadow", matEd.receiveShadow, "receive-shadow");
|
||||
ui.separator("sep-mesh");
|
||||
ui.text("Preview mesh", "mesh-title");
|
||||
ui.combo("Mesh", "UE5 Sphere\nCube\nCylinder", matEd.meshKind, "mesh");
|
||||
ui.textWrapped("Schema-aligned knobs mirror MaterialDocumentTarget shading/surface/lighting fields. Values write a live gfx.Material on the preview ball.",
|
||||
280.0, "inspector-help");
|
||||
}
|
||||
|
||||
panelBuilders <- {
|
||||
toolbar = panelToolbar,
|
||||
preview = panelPreview,
|
||||
inspector = panelInspector,
|
||||
};
|
||||
|
||||
function mountPanels() {
|
||||
for (local i = 0; i < matEd.workspace.getPanelCount(); ++i) {
|
||||
local id = matEd.workspace.getPanelId(i);
|
||||
if (!(id in panelBuilders)) continue;
|
||||
ui.beginBuild();
|
||||
ui.beginWindow(matEd.workspace.getPanelTitle(i), "root");
|
||||
panelBuilders[id]();
|
||||
ui.end();
|
||||
ui.mountBuildAs(id);
|
||||
ui.select(id);
|
||||
local region = matEd.workspace.getPanelRegion(i);
|
||||
ui.setHostPos(matEd.workspace.getRegionX(region), matEd.workspace.getRegionY(region), 0.0, 0.0);
|
||||
ui.setHostSize(matEd.workspace.getRegionW(region), matEd.workspace.getRegionH(region));
|
||||
ui.setHostOverlay(false);
|
||||
}
|
||||
}
|
||||
|
||||
function syncInspectorWidgets() {
|
||||
ui.select("inspector");
|
||||
ui.setValue("shading", matEd.shadingModel.tofloat());
|
||||
ui.setColor("tint", matEd.tintR, matEd.tintG, matEd.tintB, matEd.tintA);
|
||||
ui.setValue("metallic", matEd.metallic);
|
||||
ui.setValue("roughness", matEd.roughness);
|
||||
ui.setValue("surface", matEd.surfaceMode.tofloat());
|
||||
ui.setValue("blend", matEd.blendMode.tofloat());
|
||||
ui.setValue("alpha-cutoff", matEd.alphaCutoff);
|
||||
ui.setValue("parallax", matEd.parallax);
|
||||
ui.setChecked("depth-write", matEd.depthWrite);
|
||||
ui.setChecked("double-sided", matEd.doubleSided);
|
||||
ui.setChecked("receive-light", matEd.receiveLight);
|
||||
ui.setChecked("cast-shadow", matEd.castShadow);
|
||||
ui.setChecked("receive-shadow", matEd.receiveShadow);
|
||||
ui.setValue("mesh", matEd.meshKind.tofloat());
|
||||
}
|
||||
|
||||
function syncLabels() {
|
||||
ui.select("toolbar");
|
||||
ui.setText("toolbar-status", matEd.status);
|
||||
ui.setText("spin", matEd.autoSpin ? "Pause spin" : "Resume spin");
|
||||
ui.select("preview");
|
||||
ui.setText("preview-status",
|
||||
SHADING_MODELS[matEd.shadingModel] + " · M=" +
|
||||
format("%.2f", matEd.metallic) + " R=" + format("%.2f", matEd.roughness) +
|
||||
" · " + MESH_KINDS[matEd.meshKind]);
|
||||
}
|
||||
|
||||
function handleUiEvents() {
|
||||
local click = ui.consumeClick();
|
||||
while (click != "") {
|
||||
local event = eventParts(click);
|
||||
local id = event[1];
|
||||
if (id == "spin") {
|
||||
matEd.autoSpin = !matEd.autoSpin;
|
||||
matEd.status = matEd.autoSpin ? "Turntable spinning" : "Turntable paused";
|
||||
} else if (id.find("preset-") == 0) {
|
||||
applyPreset(id.slice(7));
|
||||
}
|
||||
click = ui.consumeClick();
|
||||
}
|
||||
|
||||
local changed = ui.consumeChange();
|
||||
while (changed != "") {
|
||||
local event = eventParts(changed);
|
||||
if (event[0] == "inspector") {
|
||||
ui.select("inspector");
|
||||
local id = event[1];
|
||||
if (id == "shading") matEd.shadingModel = ui.getValue("shading").tointeger();
|
||||
else if (id == "tint") {
|
||||
matEd.tintR = ui.getColorR("tint");
|
||||
matEd.tintG = ui.getColorG("tint");
|
||||
matEd.tintB = ui.getColorB("tint");
|
||||
matEd.tintA = ui.getColorA("tint");
|
||||
}
|
||||
else if (id == "metallic") matEd.metallic = ui.getValue("metallic");
|
||||
else if (id == "roughness") matEd.roughness = ui.getValue("roughness");
|
||||
else if (id == "surface") matEd.surfaceMode = ui.getValue("surface").tointeger();
|
||||
else if (id == "blend") matEd.blendMode = ui.getValue("blend").tointeger();
|
||||
else if (id == "alpha-cutoff") matEd.alphaCutoff = ui.getValue("alpha-cutoff");
|
||||
else if (id == "parallax") matEd.parallax = ui.getValue("parallax");
|
||||
else if (id == "depth-write") matEd.depthWrite = ui.getChecked("depth-write");
|
||||
else if (id == "double-sided") matEd.doubleSided = ui.getChecked("double-sided");
|
||||
else if (id == "receive-light") matEd.receiveLight = ui.getChecked("receive-light");
|
||||
else if (id == "cast-shadow") matEd.castShadow = ui.getChecked("cast-shadow");
|
||||
else if (id == "receive-shadow") matEd.receiveShadow = ui.getChecked("receive-shadow");
|
||||
else if (id == "mesh") {
|
||||
matEd.meshKind = ui.getValue("mesh").tointeger();
|
||||
rebuildPreviewMesh();
|
||||
}
|
||||
if (id != "mesh") {
|
||||
applyMaterial();
|
||||
matEd.status = "Live edit · " + id;
|
||||
}
|
||||
}
|
||||
changed = ui.consumeChange();
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewportCamera() {
|
||||
ui.select("preview");
|
||||
local hovered = ui.viewportHovered("mat-vp");
|
||||
local mouseX = hovered ? ui.viewportMouseX("mat-vp") : matEd.lastX;
|
||||
local mouseY = hovered ? ui.viewportMouseY("mat-vp") : matEd.lastY;
|
||||
if (hovered) {
|
||||
local wheel = ui.viewportWheel("mat-vp");
|
||||
if (wheel != 0.0) {
|
||||
if (wheel > 0.0) matEd.orbitDist = clamp(matEd.orbitDist / 1.1, 2.2, 12.0);
|
||||
else matEd.orbitDist = clamp(matEd.orbitDist * 1.1, 2.2, 12.0);
|
||||
updateOrbitCamera();
|
||||
}
|
||||
}
|
||||
local orbit = hovered && mouse.isDown(1);
|
||||
if (orbit && matEd.orbiting) {
|
||||
matEd.orbitYaw += (mouseX - matEd.lastX) * 0.01;
|
||||
matEd.orbitPitch = clamp(matEd.orbitPitch + (mouseY - matEd.lastY) * 0.008, 0.08, 1.35);
|
||||
updateOrbitCamera();
|
||||
}
|
||||
matEd.orbiting = orbit;
|
||||
matEd.lastX = mouseX;
|
||||
matEd.lastY = mouseY;
|
||||
}
|
||||
|
||||
function attachStudioIbl(camera) {
|
||||
// Metals zero diffuse GI; without an env cubemap chrome/gold read as black disks.
|
||||
// Bake a cheap six-face studio sky (same spirit as RenderImageAudit::makeStudioCubemap).
|
||||
local probe = gfx.newReflectionProbeCapture();
|
||||
probe.configure(0.0, 0.5, 0.0, 32, 0.1, 40.0);
|
||||
probe.setSkyFaceColor(0, 0.86, 0.71, 0.55); // +X warm
|
||||
probe.setSkyFaceColor(1, 0.47, 0.59, 0.82); // -X cool
|
||||
probe.setSkyFaceColor(2, 0.96, 0.96, 0.98); // +Y bright
|
||||
probe.setSkyFaceColor(3, 0.16, 0.16, 0.18); // -Y floor
|
||||
probe.setSkyFaceColor(4, 0.78, 0.82, 0.90); // +Z
|
||||
probe.setSkyFaceColor(5, 0.71, 0.63, 0.55); // -Z
|
||||
probe.setCaptureMask(0); // sky only — cheap on Lavapipe
|
||||
probe.requestCapture();
|
||||
local guard = 0;
|
||||
while (!probe.isCaptureComplete() && guard < 24) {
|
||||
probe.update(6);
|
||||
guard += 1;
|
||||
}
|
||||
if (!probe.isCaptureComplete()) {
|
||||
print("material-editor: studio IBL capture incomplete\n");
|
||||
return null;
|
||||
}
|
||||
if (!probe.stageCapturedFaces()) {
|
||||
print("material-editor: studio IBL stage failed\n");
|
||||
return null;
|
||||
}
|
||||
if (!probe.filterAndPublish(32)) {
|
||||
print("material-editor: studio IBL filter failed\n");
|
||||
return null;
|
||||
}
|
||||
local cube = probe.getActiveCubemap();
|
||||
if (cube == null) {
|
||||
print("material-editor: studio IBL cubemap missing\n");
|
||||
return null;
|
||||
}
|
||||
camera.setEnvMap(cube);
|
||||
camera.setEnvIntensity(1.45);
|
||||
return probe;
|
||||
}
|
||||
|
||||
function buildStudioLights() {
|
||||
matEd.keyLight = eve.Light3D();
|
||||
matEd.keyLight.setType("dir");
|
||||
matEd.keyLight.setDirection(-0.42, 0.88, 0.28);
|
||||
matEd.keyLight.setColor(1.0, 0.96, 0.90, 2.2);
|
||||
matEd.keyLight.setCastShadow(true);
|
||||
|
||||
matEd.fillLight = eve.Light3D();
|
||||
matEd.fillLight.setType("point");
|
||||
matEd.fillLight.setPosition(-3.2, 2.4, 3.6);
|
||||
matEd.fillLight.setColor(0.55, 0.70, 1.0, 2.0);
|
||||
matEd.fillLight.setRadius(18.0);
|
||||
|
||||
matEd.rimLight = eve.Light3D();
|
||||
matEd.rimLight.setType("point");
|
||||
matEd.rimLight.setPosition(3.4, 1.8, -2.8);
|
||||
matEd.rimLight.setColor(1.0, 0.72, 0.45, 1.6);
|
||||
matEd.rimLight.setRadius(16.0);
|
||||
}
|
||||
|
||||
function buildStudioScene() {
|
||||
matEd.camera = eve.Camera3D();
|
||||
matEd.camera.setFov(38.0);
|
||||
matEd.camera.setAmbient(0.18, 0.19, 0.21);
|
||||
matEd.camera.setActive(true);
|
||||
updateOrbitCamera();
|
||||
matEd.probe = attachStudioIbl(matEd.camera);
|
||||
buildStudioLights();
|
||||
|
||||
// Neutral studio floor so shadow / roughness response is readable.
|
||||
matEd.ground = eve.Renderable3D();
|
||||
matEd.ground.setMesh(gfx.newMeshCube(1.0));
|
||||
matEd.ground.setPosition(0.0, -1.05, 0.0);
|
||||
matEd.ground.setScale(8.0, 0.08, 8.0);
|
||||
matEd.ground.setTint(0.18, 0.19, 0.21, 1.0);
|
||||
matEd.ground.setMetallic(0.05);
|
||||
matEd.ground.setRoughness(0.88);
|
||||
matEd.ground.setCastShadow(false);
|
||||
matEd.ground.setReceiveShadow(true);
|
||||
|
||||
matEd.material = gfx.newMaterial();
|
||||
rebuildPreviewMesh();
|
||||
}
|
||||
|
||||
eve_init = function() {
|
||||
// Soft dark grey like the UE material editor preview backdrop.
|
||||
gfx.setBackgroundColor(0.12, 0.13, 0.145, 1.0);
|
||||
gfx.setDirectionalLight(-0.42, 0.88, 0.28, 1.55, 1.48, 1.35);
|
||||
configureWorkspace();
|
||||
buildStudioScene();
|
||||
ui.setTheme("dark");
|
||||
ui.setNavKeyboard(true);
|
||||
mountPanels();
|
||||
syncInspectorWidgets();
|
||||
syncLabels();
|
||||
local ibl = matEd.probe != null ? "ibl-on" : "ibl-off";
|
||||
print("material-editor: UE5 material sphere ready (" + ibl + ")\n");
|
||||
};
|
||||
|
||||
eve_update = function(dt) {
|
||||
handleUiEvents();
|
||||
handleViewportCamera();
|
||||
if (matEd.autoSpin && matEd.ball != null) {
|
||||
matEd.yaw += dt * 0.55;
|
||||
matEd.ball.setYaw(matEd.yaw);
|
||||
}
|
||||
syncLabels();
|
||||
};
|
||||
|
||||
eve_render = function() {
|
||||
gfx.clear();
|
||||
ui.select("preview");
|
||||
local canvas = ui.viewportCanvas("mat-vp");
|
||||
if (canvas != null) gfx.renderScene3DToCanvas(canvas, matEd.camera);
|
||||
ui.beginFrameAndRender();
|
||||
matEd.frame += 1;
|
||||
if (!matEd.screenshotSaved && matEd.frame > 24 && gfx.saveFramePng("material-editor.png")) {
|
||||
matEd.screenshotSaved = true;
|
||||
print("material-editor: saved material-editor.png\n");
|
||||
}
|
||||
};
|
||||
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,45 @@
|
||||
# Mesh Impact Lab(网格冲击形变)
|
||||
|
||||
把 Box3D 的**碰撞命中事件**接到网格形变会话上:飞行弹丸撞上静态目标,按命中点的法向
|
||||
冲量对目标网格做一次性塑性冲击,重建并重新上传网格后立刻可见。整个示例没有任何输入,
|
||||
启动即自动演示,用于验证“物理命中 → 网格损伤 → GPU 重新上传”这条链路。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd examples/mesh-impact-lab
|
||||
../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/mesh-impact-lab
|
||||
```
|
||||
|
||||
## 演示内容
|
||||
|
||||
1. `prototype.cylinder1` 配方生成目标网格,再经 `mesh.subdivide` + `mesh.weld` 修饰图
|
||||
加密(细分 2 级、焊接容差 `1e-4`),作为可形变的目标。
|
||||
2. `procgen.newMeshDeformationSession()` 持有该网格;`applyImpact()` 用命中点与法向
|
||||
冲量做塑性凹陷,`currentMeshResult()` 取回快照并 `procgen.uploadMesh()` 上传。
|
||||
3. 静态目标体(2.8×4.4×2.8)开启命中事件,动态球体弹丸在 X 方向以 12 m/s 射出。
|
||||
4. 命中冲量超过 `0.05` 时应用损伤,并隐藏弹丸;随后在第 45 帧之后保存截图。
|
||||
|
||||
成功标记:
|
||||
|
||||
- 控制台打印 `MESH_IMPACT_LAB_PASS impulse=... colliderPolicy=deferred impact=bounded`
|
||||
- 当前目录写出 `mesh-impact-lab.png`
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `main.nut` | 全部逻辑:配方、修饰图、物理世界、命中消费与截图 |
|
||||
| `config.nut` | 引擎 `config`:1100×700、`hotReload = true` |
|
||||
| `mesh-impact-lab.png` | 期望产出的画面(示例自带参考图) |
|
||||
|
||||
## 契约
|
||||
|
||||
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
|
||||
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
|
||||
@@ -0,0 +1,58 @@
|
||||
# Mesh Modifier Lab(网格修饰图实验室)
|
||||
|
||||
把同一份源网格(`prototype.cylinder1` 圆柱,高 4.0、细分 32)用 **13 种不同方式**变形后
|
||||
并排渲染,用来对比“类型化修饰图(`MeshModifierGraph`)”“可交互形变会话
|
||||
(`MeshDeformationSession`)”和“程序化变形节点”三条路径的覆盖面与结果。没有输入,
|
||||
启动即静态展示,适合当作 API 对照表逐个查。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd examples/mesh-modifier-lab
|
||||
../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/mesh-modifier-lab
|
||||
```
|
||||
|
||||
## 13 个变体
|
||||
|
||||
| 变体 | 用的节点 / API |
|
||||
|---|---|
|
||||
| 源网格 | `mesh.input`(未变形基准) |
|
||||
| Bend + Twist | `deform.bend`(Y 轴 28°)→ `deform.twist`(Y 轴 150°) |
|
||||
| Noise + Spherify | `deform.noise`(幅度 0.28、seed 17)→ `deform.spherify` |
|
||||
| FFD | `deform.ffd`(自由变形控制点) |
|
||||
| Cut Plane | `mesh.cutPlane`(沿 Y 切平并封盖) |
|
||||
| Spline Path | `mesh.subdivide` → `deform.splinePath`(Catmull-Rom 路径,按弧长缩放截面) |
|
||||
| Sculpt | `MeshDeformationSession`:inflate 笔刷 + 方向笔刷 + 塑性冲击 |
|
||||
| Effector | `deform.effector`(两点密度场) |
|
||||
| Interactive Surface | `applySurfaceContact` + `recoverSurface`,以及 `configureColliderRefresh` / `updateColliderRefresh` / `requestColliderRefresh` 的 interval 与 manual 两种碰撞体替换路径 |
|
||||
| Slime | `applySlimeImpulse` + `stepSlime`(弹簧-阻尼回弹) |
|
||||
| Mesh Fit | `deform.transform` 作为目标面 → `deform.meshFit` 双向贴合 |
|
||||
| Vertex Editor | `selectVerticesBox` → `moveSelectedVertices` / `manipulateSelectedVertices` |
|
||||
| Sound React | `deform.soundReact`(电平 / 阈值 / 频率驱动变形) |
|
||||
|
||||
图编译同时验证了融合优化:控制台会打印编译后的段数与融合的操作数
|
||||
(`getCompiledSegmentCount()` / `getFusedOperationCount()`)。
|
||||
|
||||
成功标记:
|
||||
|
||||
- 控制台打印 `MESH_MODIFIER_LAB_PASS variants=13 ...`
|
||||
- 当前目录写出 `mesh-modifier-lab.png`
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `main.nut` | 全部逻辑:源网格、13 个变体、相机、截图 |
|
||||
| `config.nut` | 引擎 `config`:1280×720、`hotReload = true` |
|
||||
| `mesh-modifier-lab.png` | 期望产出的画面(示例自带参考图) |
|
||||
|
||||
## 契约
|
||||
|
||||
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
|
||||
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
|
||||
@@ -1,8 +1,11 @@
|
||||
// ModelConverter demo config.
|
||||
// Adjust these to your machine.
|
||||
window_title <- "EVEngine Model Converter";
|
||||
window_width <- 1024;
|
||||
window_height <- 640;
|
||||
config <- {
|
||||
width = 1024
|
||||
height = 640
|
||||
title = "EVEngine Model Converter"
|
||||
debug = false
|
||||
hotReload = true
|
||||
};
|
||||
|
||||
// Python that has `bpy` installed (Blender's Python package). Try "python"
|
||||
// if `pip install bpy` was done into the system interpreter.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# PCG Auto Depth of Field(自动景深对焦)
|
||||
|
||||
验证 **Pcg 自动对焦**这条纯脚本链路:脚本提供一条"视线命中距离"采样,引擎按设置
|
||||
(最大距离、响应速度、跟踪模式)推进对焦状态机,输出的 `focusDistance` / `aperture`
|
||||
再由脚本写回相机景深。示例同时打开 `gbuffer` 渲染通道,保证景深合成走 GPU 路径。
|
||||
|
||||
场景是一块地面加三根近景立方体,远处再放一排立方体作为虚化对象;对焦输入是固定
|
||||
20.0 的命中距离(不依赖鼠标),因此无头运行也能得到确定结果。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd examples/pcg-depth-of-field
|
||||
../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/pcg-depth-of-field
|
||||
```
|
||||
|
||||
## 验证内容
|
||||
|
||||
- `eve.DepthOfFieldFocusSettings`(`tracking = 0` 手动、`maximumDistance = 100.0`、
|
||||
`response = 3.5`)与 `eve.DepthOfFieldFocusInput`(`hasRayHit = true`、
|
||||
`rayHitDistance = 20.0`、`deltaSeconds = 1.0`)驱动
|
||||
`eve.evaluateDepthOfFieldFocus(state, output, settings, input)`;
|
||||
- 返回结果 `!result.ok` 时直接 `throw`,不接受静默失败;
|
||||
- 求得的 `focusDistance` / `aperture` 交给 `camera.setDepthOfField(focusDistance, 5.0, 12.0)`。
|
||||
|
||||
成功标记:`PCG_DEPTH_OF_FIELD_READY focus=... aperture=... gpuComposite=active`
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `main.nut` | 场景搭建、对焦求值、写回相机景深、成功标记 |
|
||||
| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` |
|
||||
|
||||
## 契约
|
||||
|
||||
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
|
||||
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
|
||||
@@ -0,0 +1 @@
|
||||
config={width=960,height=640,title="Pcg Auto Depth of Field",debug=true,hotReload=true};
|
||||
@@ -0,0 +1,16 @@
|
||||
persist dofCamera=null
|
||||
persist dofObjects=[]
|
||||
persist dofFocusState=null
|
||||
persist dofFocusOutput=null
|
||||
function dofObject(mesh,x,y,z,s,r,g,b){local o=eve.Renderable3D();o.setMesh(mesh);o.setPosition(x,y,z);o.setScale(s,s,s);o.setTint(r,g,b,1.0);o.setRoughness(0.62);dofObjects.push(o);return o;}
|
||||
eve_init=function(){
|
||||
gfx.setBackgroundColor(0.025,0.04,0.07,1.0);dofCamera=eve.Camera3D();dofCamera.setEye(0.0,3.5,12.0);dofCamera.setTarget(0.0,1.0,-14.0);dofCamera.setClipPlanes(0.1,80.0);dofCamera.setAmbient(0.3,0.32,0.38);
|
||||
local cube=gfx.newMeshCube(1.0);local ground=eve.Renderable3D();ground.setMesh(cube);ground.setPosition(0.0,-1.3,-18.0);ground.setScale(28.0,0.5,48.0);ground.setTint(0.12,0.16,0.14,1.0);dofObjects.push(ground);dofObject(cube,-3.2,1.0,-7.0,2.5,0.18,0.82,0.42);dofObject(cube,0.0,1.0,-8.0,2.5,0.2,0.68,0.95);dofObject(cube,3.2,1.0,-9.0,2.5,0.9,0.72,0.18);
|
||||
for(local i=-4;i<=4;i+=2)dofObject(cube,i.tofloat(),2.0,-38.0,2.2,0.92,0.16,0.2);
|
||||
local rc=gfx.getRenderControl();rc.enable("gbuffer");rc.compile();
|
||||
local settings=eve.DepthOfFieldFocusSettings();settings.tracking=0;settings.maximumDistance=100.0;settings.response=3.5;local input=eve.DepthOfFieldFocusInput();input.hasRayHit=true;input.rayHitDistance=20.0;input.deltaSeconds=1.0;dofFocusState=eve.DepthOfFieldFocusState();dofFocusOutput=eve.DepthOfFieldFocusOutput();local result=eve.evaluateDepthOfFieldFocus(dofFocusState,dofFocusOutput,settings,input);if(!result.ok)throw "pcg-dof focus failed";
|
||||
dofCamera.setDepthOfField(dofFocusOutput.focusDistance,5.0,12.0);
|
||||
print("PCG_DEPTH_OF_FIELD_READY focus="+dofFocusOutput.focusDistance+" aperture="+dofFocusOutput.aperture+" gpuComposite=active\n");
|
||||
};
|
||||
eve_update=function(dt){};
|
||||
eve_render=function(){gfx.clear();gfx.render3D();};
|
||||
@@ -0,0 +1,45 @@
|
||||
# PCG Terrain Detail Overwrite(地形细节覆盖)
|
||||
|
||||
验证 **Pcg 细节覆盖**:脚本用 `PointSet` 描述一批草地点(位置、缩放、颜色、`asset`
|
||||
字符串属性与稳定 Point ID),把程序化生成的 albedo / normal / mask 三张图烘焙成
|
||||
`GrassField` 植被,再用 `setTerrainDetailOverwrite` 一次性覆盖地形的细节距离、淡出
|
||||
距离、密度与每 patch 分辨率,并读回校验。整条链路不依赖任何下载资源或运行时 GLSL。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
cd examples/pcg-detail-overwrite
|
||||
../../build/linux-debug/src/engine/eve run
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```bash
|
||||
make run/<platform>-debug GAME=examples/pcg-detail-overwrite
|
||||
```
|
||||
|
||||
## 验证内容
|
||||
|
||||
- 25×17 的草地点阵:逐点 `setScale` / `setColor` / `setStringAttribute(i, "asset", "pcg:detail")`,
|
||||
最后 `assignPointIds("4242")` 固定 Point ID;
|
||||
- 32×32 的 RGBA8 albedo / normal / mask 图像逐像素程序化生成(草叶 alpha 与 mask 通道);
|
||||
- `eve.bakeTerrainGrassFoliage(field, points, "pcg:detail", 0.8, 1.25, albedo, normal, mask, foliage)`
|
||||
上传植被,`GrassFoliageSettings` 给出基础色、alpha 裁剪、法线强度、渲染/淡出/硬距离与密度;
|
||||
- `field.setTerrainDetailOverwrite(settings)` 覆盖细节参数:Pcg 细节距离 70 / 淡出 18,
|
||||
Unity 细节距离 110 / 密度 0.55 / 每 patch 8;
|
||||
- 断言返回质量档位为 `3`(High8)、`getTerrainDetailHardDistance() == 110.0`、
|
||||
`getTerrainDetailDensity() == 0.55`,任一不符即 `throw`。
|
||||
|
||||
成功标记:`PCG_DETAIL_OVERWRITE_READY quality=High8 hard=110 density=0.55 fade=70+18 points=<点数>`
|
||||
|
||||
## 文件
|
||||
|
||||
| 路径 | 说明 |
|
||||
|---|---|
|
||||
| `main.nut` | 点阵构造、程序化贴图、植被烘焙、细节覆盖与断言 |
|
||||
| `config.nut` | 引擎 `config`:960×640、`hotReload = true` |
|
||||
|
||||
## 契约
|
||||
|
||||
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
|
||||
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
|
||||
@@ -0,0 +1,6 @@
|
||||
config = {
|
||||
title = "Pcg Terrain Detail Overwrite",
|
||||
width = 960,
|
||||
height = 640,
|
||||
hotReload = true,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
local retained=[];
|
||||
local field=null;
|
||||
local camera=null;
|
||||
|
||||
function keep(value) { retained.append(value); return value; }
|
||||
function need(result, label) {
|
||||
if(!result.ok) throw "pcg-detail-overwrite: "+label+" failed";
|
||||
return result.value;
|
||||
}
|
||||
|
||||
eve_init <- function() {
|
||||
gfx.setBackgroundColor(0.035,0.055,0.075,1.0);
|
||||
gfx.setDirectionalLight(-0.45,0.85,0.35,1.6,1.48,1.25);
|
||||
camera=keep(eve.Camera3D());
|
||||
camera.setEye(0.0,5.2,11.0);camera.setTarget(0.0,1.0,0.0);camera.setUp(0.0,1.0,0.0);
|
||||
camera.setFov(48.0);camera.setClipPlanes(0.1,100.0);camera.setAmbient(0.42,0.48,0.55);camera.setActive(true);
|
||||
|
||||
local points=keep(need(procgen.newPointSet(),"point set"));
|
||||
for(local z=-8;z<=8;++z) for(local x=-12;x<=12;++x) {
|
||||
local px=x*0.38+(z%2)*0.17,pz=z*0.42;
|
||||
local i=points.add(px,0.0,pz);
|
||||
local h=0.75+((x*x+z*z)%7)*0.055;
|
||||
local w=0.7+((x+z+40)%5)*0.09;
|
||||
points.setScale(i,w,h,w);points.setStringAttribute(i,"asset","pcg:detail");
|
||||
points.setColor(i,0.48+((x+20)%4)*0.08,0.72+((z+20)%3)*0.07,0.28,1.0);
|
||||
}
|
||||
points.assignPointIds("4242");
|
||||
|
||||
local imageApi=eve.Image();
|
||||
local albedo=keep(imageApi.newEmptyImageData(32,32,"RGBA8"));
|
||||
local normal=keep(imageApi.newEmptyImageData(albedo.getWidth(),albedo.getHeight(),"RGBA8"));
|
||||
local mask=keep(imageApi.newEmptyImageData(albedo.getWidth(),albedo.getHeight(),"RGBA8"));
|
||||
for(local y=0;y<albedo.getHeight();++y) for(local x=0;x<albedo.getWidth();++x) {
|
||||
local nx=(x-15.5)/15.5,ny=y/31.0;
|
||||
local blade=abs(nx)<(0.08+0.34*ny);
|
||||
albedo.setPixel(x,y,0.34+0.2*ny,0.72+0.2*ny,0.18,blade?1.0:0.0);
|
||||
normal.setPixel(x,y,0.5,0.5,1.0,1.0);mask.setPixel(x,y,0.05,0.82,0.22,0.72);
|
||||
}
|
||||
field=keep(gfx.newGrassField());
|
||||
local foliage=eve.GrassFoliageSettings();
|
||||
foliage.baseR=0.9;foliage.baseG=1.0;foliage.baseB=0.82;foliage.alphaCutoff=0.08;
|
||||
foliage.normalStrength=1.0;foliage.renderDistance=70.0;foliage.fadeRange=18.0;
|
||||
foliage.hardRenderDistance=110.0;foliage.density=1.0;
|
||||
foliage.snowMinimumHeight=100.0;foliage.snowFadeDistance=20.0;foliage.snowProgress=0.0;
|
||||
need(eve.bakeTerrainGrassFoliage(field,points,"pcg:detail",0.8,1.25,albedo,normal,mask,foliage),"foliage upload");
|
||||
local settings=eve.TerrainDetailOverwriteSettings();
|
||||
settings.pcgDetailDistance=70.0;settings.pcgFadeoutDistance=18.0;
|
||||
settings.unityDetailDistance=110;settings.unityDetailDensity=0.55;settings.detailResolutionPerPatch=8;
|
||||
local quality=need(field.setTerrainDetailOverwrite(settings),"detail overwrite");
|
||||
if(quality!=3 || field.getTerrainDetailHardDistance()!=110.0 || field.getTerrainDetailDensity()!=0.55)
|
||||
throw "detail overwrite contract mismatch";
|
||||
print("PCG_DETAIL_OVERWRITE_READY quality=High8 hard=110 density=0.55 fade=70+18 points="+points.getCount()+"\n");
|
||||
};
|
||||
|
||||
eve_update <- function(dt) { if(field!=null) field.update(dt); };
|
||||
eve_render <- function() { gfx.clear();gfx.render3D();if(field!=null) field.draw(); };
|
||||