diff --git a/.github/scripts/consumer-test.sh b/.github/scripts/consumer-test.sh index 3f690873..24bfa661 100644 --- a/.github/scripts/consumer-test.sh +++ b/.github/scripts/consumer-test.sh @@ -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: diff --git a/.github/scripts/run-consumer-apks.sh b/.github/scripts/run-consumer-apks.sh new file mode 100755 index 00000000..a58a74dd --- /dev/null +++ b/.github/scripts/run-consumer-apks.sh @@ -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 diff --git a/.github/scripts/test-sdk.sh b/.github/scripts/test-sdk.sh index 8f6c3d94..70e0a71c 100755 --- a/.github/scripts/test-sdk.sh +++ b/.github/scripts/test-sdk.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# test-sdk.sh [expected-version] +# test-sdk.sh [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 [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 --------------------------------------------------- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb83db9a..2baebf69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,14 @@ jobs: timeout-minutes: 5 run: python3 scripts/check_test_manifest.py + - name: Check examples layout and overview registration + # Every examples// 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 diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml index 90a23325..c7d0be78 100644 --- a/.github/workflows/sdk-release.yml +++ b/.github/workflows/sdk-release.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 02ccba08..4aa2f55d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ` launches an example headless and greps for error - markers (PASS/FAIL) — a good quick check under `xvfb-run`. +- `scripts/smoke_examples.sh ` 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//` 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 ` 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. diff --git a/CMakeLists.txt b/CMakeLists.txt index ecd0b67c..9f249ac3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}") diff --git a/Makefile b/Makefile index 90547be6..78c05a60 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/cmake/install_sdk.cmake b/cmake/install_sdk.cmake index fc989a92..c109c11f 100644 --- a/cmake/install_sdk.cmake +++ b/cmake/install_sdk.cmake @@ -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) diff --git a/cmake/module_manifest/core_foundation.cmake b/cmake/module_manifest/core_foundation.cmake index 8f279fba..8c6bebc7 100644 --- a/cmake/module_manifest/core_foundation.cmake +++ b/cmake/module_manifest/core_foundation.cmake @@ -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 diff --git a/cmake/module_manifest/input_playback.cmake b/cmake/module_manifest/input_playback.cmake index 4cb8368d..4e4b23a1 100644 --- a/cmake/module_manifest/input_playback.cmake +++ b/cmake/module_manifest/input_playback.cmake @@ -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) diff --git a/cmake/module_manifest/orchestration.cmake b/cmake/module_manifest/orchestration.cmake index 9a5359c0..1d413a0f 100644 --- a/cmake/module_manifest/orchestration.cmake +++ b/cmake/module_manifest/orchestration.cmake @@ -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 diff --git a/cmake/module_manifest/rendering_simulation.cmake b/cmake/module_manifest/rendering_simulation.cmake index 31958d28..0deaf628 100644 --- a/cmake/module_manifest/rendering_simulation.cmake +++ b/cmake/module_manifest/rendering_simulation.cmake @@ -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) diff --git a/cmake/third_party_build.cmake b/cmake/third_party_build.cmake index ddbe29bf..3f09e3ec 100644 --- a/cmake/third_party_build.cmake +++ b/cmake/third_party_build.cmake @@ -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 or . + ${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 diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 3e5c1acc..58b1b96c 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -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) diff --git a/docs/dev/TileWorldCreator4核心移植.md b/docs/dev/TileWorldCreator4核心移植.md new file mode 100644 index 00000000..b9bbeb03 --- /dev/null +++ b/docs/dev/TileWorldCreator4核心移植.md @@ -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`:独立确定性算法实现。 diff --git a/docs/dev/Tilemap设计.md b/docs/dev/Tilemap设计.md index e00fb8ec..6a371130 100644 --- a/docs/dev/Tilemap设计.md +++ b/docs/dev/Tilemap设计.md @@ -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`,即可生成草地、沙地、石头、水体四材质预览; +默认直接读取随测试打包的三张图集,也可通过对应环境变量替换素材。 diff --git a/docs/dev/canonical-mesh-v2.md b/docs/dev/canonical-mesh-v2.md index 754a22d9..61802aed 100644 --- a/docs/dev/canonical-mesh-v2.md +++ b/docs/dev/canonical-mesh-v2.md @@ -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 diff --git a/docs/dev/dnut序列语言与解释器.md b/docs/dev/dnut序列语言与解释器.md new file mode 100644 index 00000000..8f36233a --- /dev/null +++ b/docs/dev/dnut序列语言与解释器.md @@ -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 // 跨字段的领域不变量 +} +``` + +- `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.*" +``` diff --git a/docs/dev/hex-terrain-capability-audit.md b/docs/dev/hex-terrain-capability-audit.md index 013a4cdd..2f8dd9ab 100644 --- a/docs/dev/hex-terrain-capability-audit.md +++ b/docs/dev/hex-terrain-capability-audit.md @@ -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. + diff --git a/docs/dev/superpowers/plans/2026-09-15-dnut-rpg-dialect.md b/docs/dev/superpowers/plans/2026-09-15-dnut-rpg-dialect.md new file mode 100644 index 00000000..6ef409a3 --- /dev/null +++ b/docs/dev/superpowers/plans/2026-09-15-dnut-rpg-dialect.md @@ -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 [repeatable] { + move actor= x= y= [duration=] + animation target= clip= [loop] [hold] + select object= [prompt=] + skill actor= learn= # forget= 亦为同一步骤的模式 + attribute actor= name= op=set|add value= + item add= [count=] | remove= [count=] + equipment actor= equip= | unequip= + message text= + dialogue id= + wait duration= + camera x= y= [duration=] + if { ... } [else { ... }] + choice { option "label" [when ] -> } + call + 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(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(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` 并核对效果。 + diff --git a/docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md b/docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md new file mode 100644 index 00000000..be3ff208 --- /dev/null +++ b/docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md @@ -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 // 领域不变量(如 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 +``` + +帧内 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` 段内。 +- **声明形态**: + + ```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` 是否同时承担了「语言」与「运行时」两个职责而需要再切分(待验证)。 diff --git a/docs/dev/发布流程.md b/docs/dev/发布流程.md index 733cce67..54c36085 100644 --- a/docs/dev/发布流程.md +++ b/docs/dev/发布流程.md @@ -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 diff --git a/docs/dev/战斗系统与动作编辑器.md b/docs/dev/战斗系统与动作编辑器.md index 1df65e82..19a038d5 100644 --- a/docs/dev/战斗系统与动作编辑器.md +++ b/docs/dev/战斗系统与动作编辑器.md @@ -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 推进;各状态仍由所属模块唯一拥有。 diff --git a/docs/dev/程序化道路系统设计.md b/docs/dev/程序化道路系统设计.md new file mode 100644 index 00000000..9c81a4a4 --- /dev/null +++ b/docs/dev/程序化道路系统设计.md @@ -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` / 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` 中心线自动导入 diff --git a/docs/images/pcg-location-system.png b/docs/images/pcg-location-system.png new file mode 100644 index 00000000..dd90b148 Binary files /dev/null and b/docs/images/pcg-location-system.png differ diff --git a/docs/images/pcg-scene-player-culling.png b/docs/images/pcg-scene-player-culling.png new file mode 100644 index 00000000..8c741c85 Binary files /dev/null and b/docs/images/pcg-scene-player-culling.png differ diff --git a/docs/images/pcg-snow-wind.png b/docs/images/pcg-snow-wind.png new file mode 100644 index 00000000..4cbb8abb Binary files /dev/null and b/docs/images/pcg-snow-wind.png differ diff --git a/docs/images/pcg-thunder-strike.png b/docs/images/pcg-thunder-strike.png new file mode 100644 index 00000000..29b4ab6d Binary files /dev/null and b/docs/images/pcg-thunder-strike.png differ diff --git a/docs/usr/modules/animation.md b/docs/usr/modules/animation.md index ecd44515..831ef1b8 100644 --- a/docs/usr/modules/animation.md +++ b/docs/usr/modules/animation.md @@ -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()` 定义逐骨权重。 diff --git a/docs/usr/modules/audio.md b/docs/usr/modules/audio.md index ee74c54e..07be39a3 100644 --- a/docs/usr/modules/audio.md +++ b/docs/usr/modules/audio.md @@ -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)` 原子应用命令。 diff --git a/docs/usr/modules/camera.md b/docs/usr/modules/camera.md index b8858c73..f0bcc33a 100644 --- a/docs/usr/modules/camera.md +++ b/docs/usr/modules/camera.md @@ -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 状态一起逆序恢复。 diff --git a/docs/usr/modules/combat.md b/docs/usr/modules/combat.md new file mode 100644 index 00000000..33772e9a --- /dev/null +++ b/docs/usr/modules/combat.md @@ -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 中复制冷却、动作阶段或生命值。 diff --git a/docs/usr/modules/editor.md b/docs/usr/modules/editor.md index 0c96921a..671652cd 100644 --- a/docs/usr/modules/editor.md +++ b/docs/usr/modules/editor.md @@ -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); diff --git a/docs/usr/modules/graphics.md b/docs/usr/modules/graphics.md index 01cb39bb..f04abcb7 100644 --- a/docs/usr/modules/graphics.md +++ b/docs/usr/modules/graphics.md @@ -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()` 返回最近状态与采样坐标。 diff --git a/docs/usr/modules/hexmap.md b/docs/usr/modules/hexmap.md new file mode 100644 index 00000000..7761752c --- /dev/null +++ b/docs/usr/modules/hexmap.md @@ -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`: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=} +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` 没有移植过来。球面地图也**没有存档格式**。 diff --git a/docs/usr/modules/image.md b/docs/usr/modules/image.md index 5e9d18c9..e1d3624c 100644 --- a/docs/usr/modules/image.md +++ b/docs/usr/modules/image.md @@ -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()`、 diff --git a/docs/usr/modules/particles.md b/docs/usr/modules/particles.md index 82ddaacc..82358fa3 100644 --- a/docs/usr/modules/particles.md +++ b/docs/usr/modules/particles.md @@ -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 原有纹理。 diff --git a/docs/usr/modules/physics.md b/docs/usr/modules/physics.md index acdf352f..90325556 100644 --- a/docs/usr/modules/physics.md +++ b/docs/usr/modules/physics.md @@ -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()` 可用于存档外的运行诊断。 + ## 使用要点 - 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。 diff --git a/docs/usr/modules/procgen.md b/docs/usr/modules/procgen.md index 3f64de63..4a3ffcd6 100644 --- a/docs/usr/modules/procgen.md +++ b/docs/usr/modules/procgen.md @@ -458,9 +458,13 @@ local height = hm.sampleBilinear(3.0, 4.0); // EVTRN 已是米 | --- | --- | --- | --- | | `EVTR` | `TerrainAsset::bake` 的分块归档(magic `EVTR`) | UNORM16,按归档 header 的 `minHeight`/`maxHeight` 反量化为米 | 否(`hasSpacing == false`) | | `EVTRN` | `eve.terrain/1` 资产旁的 `heightfield.bin`(magic `EVTRN\0\1\0`) | 原始 float32 米 | 是 | +| `raw8` | Pcg 无头方形 RAW | UNORM8,X 外层/Z 内层 | 否 | +| `raw16le` | Pcg IBM byte order RAW | 小端 UNORM16,X 外层/Z 内层 | 否 | +| `raw16be` | Pcg Mac byte order RAW | 大端 UNORM16,X 外层/Z 内层 | 否 | 返回值除 `value`(高度场代理)外还带 `spacingX`、`spacingZ`、`hasSpacing`、`minHeight`、 `maxHeight`、`format`、`width`、`height`。`loadTerrainBytes` 同上,但直接吃内存字节 +。RAW 没有 magic,必须显式指定格式;解码器要求精确平方样本数,避免 Pcg 使用 ceil(sqrt) 后读取文件尾外。 (`file(path,"r").read()` 得到的 Squirrel 字符串是二进制安全的)。 失败时返回结构化 `Result`:文件缺失是 `not_found`,magic 不匹配是 `parse_error`,无法识别的 @@ -494,6 +498,13 @@ UNORM16,汇流/湖深/温湿度使用 UNORM8,连续流向使用两个 SNORM8 连续流向与 Strahler 河序在块接缝两侧保持一致。若相邻块尚未驻留,这些接口明确 返回失败,调用方可遵守加载预算延后构网,而不会把缺失邻块误判为河流出口。 +PhotoMode 的流式设置由 `PcgTerrainStreamingAuthority` 连接到上述缓存。调用方通过 +`setTarget(cache, worldUnitsPerSample)` 借用缓存并声明一个高度采样点对应的世界尺度, +再用显式 authority 租约接管 `m_pcgLoadRange` 与 `m_pcgImpostorRange`。两个值保留 +Pcg 的世界空间半径语义,转换到 chunk 半径时向上取整;`streamAround()` 使用常规范围 +驱动真实块驻留,`tierForDistance()` 则将距离分为 Regular、Impostor 和 Unloaded。 +缓存必须比 authority 活得更久,销毁或换场景前应先解除目标或销毁 authority。 + `ProcgenTerrainMeshChunk` 的基础网格按 `2^lod` 采样,高度法线始终从原始全分辨率 高度场求导,因此同边相邻块不会出现光照法线断层。块四周生成可配置深度的边裙, 用于遮盖不同 LOD 之间的 T 型接缝。每个顶点另有四个归一化材质权重,可通过 @@ -1627,9 +1638,1409 @@ local scatter = procgen.poissonDisk(100, 100, 2.5, 99, 500); // 最多 500 点 ## 使用要点 +### 高度图印章与顺序蒙版(CPU) + +`ProcgenHeightmap.blendMask(source, mode, strength, invert)` 在接收者上顺序组合 +同尺寸蒙版,返回标准 Result,`value` 是变化的采样数。`mode` 为 +0 Multiply、1 Maximum、2 Minimum、3 Add、4 Subtract。先按 `invert` +选择 `source` 或 `1-source`,执行运算,再按 `[0,1]` 内的 `strength` +从原值插值;中间值不钳制,所以连续调用的顺序有意义。 + +`TerrainStampSettings()` 是脚本持有的参数对象: + +- `setGrid(originX, originZ, spacingX, spacingZ)` 设置接收高度图的世界原点与采样间距。 +- `setCenter(x, z)`、`setSize(width, depth)`、`setRotation(radians)` 设置印章位置和旋转。 +- `amplitude`、`baseHeight`、`blendStrength` 为可写浮点字段,默认值分别为 1、0、0.5。 + +脚本浮点参数使用 `1.0` 这样的浮点字面量;当前 VM 为单精度。C++ 世界坐标 +配置保留 double,脚本不能提供超出 VM 数值精度的坐标。 + +`ProcgenHeightmap.applyStamp(stamp, settings, operation, localMask, globalMask)` +返回标准 Result,`value` 为变化的采样数。`operation` 为 0 Raise、1 Lower、 +2 Blend、3 Set、4 Add、5 Subtract。Raise/Lower 取高度包络;Blend 使用 +`blendStrength`;Add/Subtract 对已有高度加减印章高度。 + +印章和两个蒙版使用同一旋转矩形内的 UV,分别双线性采样,分辨率可不同。 +局部蒙版作用于 `stamp * localMask`,再乘 `amplitude`、加 `baseHeight`; +全局蒙版钳制到 `[0,1]` 后控制最终高度变化。值为 1 的单像素蒙版表示无过滤。 +旋转矩形外不变;地形 `(x,y)` 的世界位置为 +`(originX+x*spacingX, originZ+y*spacingZ)`,相邻瓦片可共享边界坐标。 + +```squirrel +local terrain = procgen.newHeightmap(65, 65).value; +local stamp = procgen.newHeightmap(2, 2).value; +stamp.setHeight(0, 0, 0.0); stamp.setHeight(1, 0, 0.0); +stamp.setHeight(0, 1, 0.0); stamp.setHeight(1, 1, 1.0); +local one = procgen.newHeightmap(1, 1).value; +one.setHeight(0, 0, 1.0); +local settings = eve.TerrainStampSettings(); +settings.setCenter(32.0, 32.0); +settings.setSize(40.0, 24.0); +settings.setRotation(0.3); +settings.amplitude = 12.0; +local applied = terrain.applyStamp(stamp, settings, 0, one, one); +assert(applied.ok); +``` + +调用同步执行,不保留传入对象。调用者必须独占目标高度图;同一对象作为输入 +与输出也按修改前数据计算。输入尺寸、有限值、枚举与参数先检查,结果溢出也 +拒绝整个操作,不留下部分写入。内存分配失败按 C++ 分配异常处理。 +该接口使用原生地形高度单位,不模拟 Unity packed height、自适应基底或 +MixHeight;本接口自身不创建编辑器撤销记录,事务历史由拥有式 workspace/session 提供。 + +### 蒙版生成与曲线纹理 + +以下都是 `ProcgenHeightmap` 方法,修改接收者并返回标准 Result(`value` 为变化 +的采样数)。运算先构建候选结果,再统一发布;源、曲线和目标允许别名。 +所有输入必须有限,曲线必须是非空的一行高度图。 + +- `transformMask(source, curve)`:以源标量为 UV,通过 Clamp/Bilinear 曲线纹理变换。 +- `rangeMask(source, minimum, maximum, filterCurve, strengthCurve)`:先计算 + `smoothstep(minimum, maximum, source)`,再依次采样过滤曲线和强度曲线。 + `minimum < maximum`;用世界高度范围即可生成高度适应度。 +- `deriveSlope(heights, spacingX, spacingZ, heightScale)`:从按世界间距缩放的高度 + 导数生成 `1-normal.y`,**不是坡度角度**。输出可继续传入 `rangeMask`。 + 内部用中心差分,边缘单边差分;单采样轴的导数为零。分块处理时应提供 + 邻接 halo 再裁切,不能把没有邻块的边缘差分当成跨瓦片连续性保证。 +- `distanceMask(settings, axis, filterCurve, strengthCurve)`:距离过滤后再应用 + 强度曲线。`axis` 为 0 圆、1 X、2 Z、3 圆角方形。 + +`TerrainDistanceMaskSettings()` 提供浮点字段 `offsetX`、`offsetZ`、`scaleX`、 +`scaleZ`、`rotation`(弧度)、`roundness`,以及布尔字段 `tiling`。 +默认偏移和旋转为零,缩放为 1,圆角指数为 0.5,不重复。缩放非零、圆角指数 +严格为正。圆角方形范围外的过滤值为零,仍需经过强度曲线,因而不一定输出黑色。 + +曲线按 GPU 纹理坐标采样:第 i 个 texel 中心在 `(i+0.5)/width`。因此两像素 +曲线 `[0,1]` 在 UV=0.25 处为 0、0.5 处为 0.5、0.75 处为 1,不能视作 +两个端点之间的普通线性函数。可以提供原包导出的 256×1 曲线数据;目前没有 +实现 Unity AnimationCurve 资产解析。距离蒙版的输出 UV 也取像素中心。 + - 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。 - 带 `update(dt)` 的系统应在 `eve_update` 调用;绘制方法应在 `eve_render` 调用。 - 参数约束、默认值和返回类型以对应模块头文件及 `addFunc` 绑定为准;本文 API 快查与当前源码同步生成。 **源码:** [`src/modules/procgen/`](../../../src/modules/procgen/) **相关测试:** 在 [`test/`](../../../test/) 中搜索 `procgen`;跨模块 hex 关卡见 [`test/hex_level_simulation.cpp`](../../../test/hex_level_simulation.cpp)、[`test/hex_level_data.cpp`](../../../test/hex_level_data.cpp)、[`examples/hex-levels/`](../../../examples/hex-levels/)。 + +### Terrain contrast + +`ProcgenHeightmap.applyContrast(mask, strength, featureSize)` returns a structured Result with the changed sample count. C++ exposes `applyTerrainContrast` in `TerrainEffect.h`. The mask must match the destination dimensions and contain values in [0,1]; strength is finite and nonnegative (values above 1 extrapolate; Pcg defaults to 2), and featureSize is a nonnegative texel offset. Fractional offsets use clamped bilinear sampling. The nine-tap average weights the center and axial samples by 1 and diagonal samples by 0.75 (total weight 8). The output is `height + 0.5 * (height - average) * strength * mask`. + +The operation reads an immutable snapshot, including when the mask aliases the destination, and publishes atomically. Invalid inputs and float overflow leave the destination unchanged. Heights remain in native caller units without Unity packing or saturation. Filtering across tiles requires a supplied neighbor halo and cropping. Execution is synchronous with exclusive destination access and no retained references, callbacks, RNG or time dependency. + +### 地形效果(CPU) + +`TerrainEffect.h` 中的原生操作均同步执行,借用输入,不保留指针、回调或外部状态。 +目标必须由调用者独占;先计算完整候选再发布,失败不留下部分高度修改。 +Squirrel 接口返回标准 Result,`value` 为最终变化的采样数。蒙版与目标尺寸相同, +混合权重在 `[0,1]`;重复效果中的蒙版始终使用调用开始时的数据,允许输入别名。 +这批操作没有新 ECS、持久数据格式、可选服务、时钟或 RNG。 + +- `applySmooth(mask, settings)`,参数对象 `eve.TerrainSmoothSettings()`: + `radius=10`(非负 texel 间距)、`verticality=0`(`[-1,1]`)、`strength=1`。 + 先水平后垂直,每遍为中心加七对加权采样。`verticality=-1` 只降低、`1` 只抬高。 + 保留原 Shader 两遍都使用 X 方向 texel 大小的行为:非方形栅格的垂直采样间距 + 为 `radius*height/width`。Clamp/Bilinear 边缘寻址;提供 halo 后裁切时须保持 + 该采样比例,不能假定不同宽高比的独立块自动无缝。 +- `applyRidges(mask, settings)`,参数对象 `eve.TerrainRidgeSettings()`: + `mixStrength=0.5`、`exponent=16`、`strength=1`、`minimum=0`、`maximum=0.5`、 + `passes=18`。每遍先做水平区间幂变换,再将更新后的值用于垂直区间幂变换, + 与四邻域均值混合,最后按蒙版插值并裁剪。`passes` 是实际应用次数, + Pcg 编辑器迭代参数 N 对应 `floor(N)+2` 次;0 次保持不变。 + 裁剪在蒙版插值之后,蒙版为零也会裁剪超出 bounds 的值。 + 世界高度调用者须设置 `minimum/maximum`;默认值对应原 Shader 的标量域。 +- `applyTerrace(mask, settings)`,参数对象 `eve.TerrainTerraceSettings()`: + `count=100`、`bevel=0`、`strength=1`。count 是高度单位的倒数,必须为正。 + 高度乘 count 后按最近偶数舍入;只有正方向余量严格大于 `1-bevel` 时保留 + 原高度,随后插值。没有加入原源码中已注释掉的 jitter 或未使用的外侧 bevel。 + 半整数舍入遵循 [HLSL round 文档](https://learn.microsoft.com/en-us/windows/win32/direct3dhlsl/dx-graphics-hlsl-round)。 +- `applyPower(mask, power)`:指数是 `4-power`,不是 power 本身。 + 不自动归一化输入;遮罩内负高度,以及指数非正时的零高度,返回失败以避免 + 未定义幂运算。零权重样本不求幂。超出有限 float 范围也拒绝整个操作。 +- `applyHeightCurve(mask, curve, minimum, maximum)`:曲线为非空一行 LUT, + 使用 `smoothstep(minimum,maximum,height)` 和 Clamp/Bilinear texel-center 采样。 + 曲线值乘 `maximum-minimum` 后与原高度按蒙版插值;保留原公式不加回 minimum + 的行为。minimum 必须小于 maximum。 +- `applyHeightMix(local, global, settings)`,参数对象 `eve.TerrainHeightMixSettings()`: + `minimum=0`、`maximum=1`、`midpoint=0.5`、`strength=0.5`、 + `clipMinimum=0`、`clipMaximum=0.5`。增量为 + `(local-midpoint)*(maximum-minimum)*strength*global`,加到原高度后裁剪。 + local 为有限标量,可超出 `[0,1]`;global 和 midpoint 为单位范围,strength + 可大于 1(原编辑器允许到 2)。允许 minimum 等于 maximum,表示零高度跨度。 + global 为零仍执行最终裁剪。 + +```squirrel +local target = procgen.newHeightmap(33, 33).value; +local mask = procgen.newHeightmap(33, 33).value; +for (local y=0; y<33; ++y) for (local x=0; x<33; ++x) { + target.setHeight(x, y, x == 16 && y == 16 ? 0.5 : 0.0); + mask.setHeight(x, y, 1.0); +} +local smooth = eve.TerrainSmoothSettings(); +smooth.radius = 1.0; +assert(target.applySmooth(mask, smooth).ok); +local terrace = eve.TerrainTerraceSettings(); +terrace.count = 100.0; +assert(target.applyTerrace(mask, terrace).ok); +``` + +所有算子保留调用者传入的标量域,不自动模拟 Unity 的高度纹理打包。非线性 +效果若要与原参数对应,调用方必须提供对应的高度标量域和裁剪范围。这里处理 +已光栅化的整个操作区域;旋转笔刷、范围外保留及多瓦片的上下文仍由上层提供。 +CPU 使用固定采样顺序和 double 中间值,float 存储;跨平台/CPU-GPU 比较需使用 +数值容限,不承诺位级一致。这些接口尚不能作为原版 GPU 或可视化验收的证据。 + +### 八邻域热侵蚀 + +`ProcgenHeightmap.applyThermal(sediment, settings)` 对接收高度图和指定泥沙累计图 +同时计算,再统一发布;返回标准 Result,`value` 是任一输出发生变化的网格数。 +C++ 入口为 `TerrainErosion.h` 中的 `applyTerrainThermal`。两个目标必须是尺寸 +相同的不同对象,由调用者独占;高度为非负有限值,泥沙允许有限的负值。 +输入或中间结果无效、浮点溢出、内存分配失败,都不发布任何一个输出。 + +`eve.TerrainThermalSettings()` 字段:`spacingX=1`、`spacingZ=1`、`heightScale=1`、 +`reposeSlope=11.430052`、`dt=0.00025`、`iterations=3`。间距和高度比例严格为正; +坡度阈值、dt、迭代数非负。reposeSlope 是休止角的正切,不是角度值。 +例如默认值约为 tan(85°)。dt 是调用者注入的热侵蚀子步时长;对齐 Pcg 的 +组合模拟时,它等于 thermalTimeDelta * hydroTimeDelta。没有隐式时钟或 RNG。 + +每个子步读取旧高度快照,用 Clamp 邻域的四个正向样本和四个对角样本求坡度。 +仅绝对坡度严格大于 reposeSlope 的高度差参与求和;对角权重为 0.707,正向 +权重为 1。差值先乘 heightScale。移动量为 `clamp(dt*sum/16,-height/2,height/2)`, +从高度扣除并加到泥沙图中。泥沙正值表示移除,负值表示加回,不能将它直接 +解释为非负悬浮泥沙浓度。零高度格子的移动上限也为零,这是原公式的行为。 + +本接口保留原 Thermal.compute 的标量语义,不替换现有 TerrainPipeline 的 +保守 talus 重分配。高度与泥沙之和在每格保持平衡(受 float 舍入影响),不 +承诺仅高度图的总质量守恒。重复调用可传回这两张输出继续迭代。无保留引用、 +回调或跨帧可变状态;同步调用期间不能并发观察或修改任一目标。仍不代表完整 +水力/热侵蚀联合管线或 Unity GPU 对照已经通过。 + +### 水流状态与分阶段计算 + +`eve.TerrainWaterField()` 创建一个独占拥有水深、速度和四向通量的空状态。 +`reset(width, height, depth)` 返回 Result,成功后重置网格及均匀水深,并清空速度 +和通量;尺寸乘积必须不超过 int 上限。`getWidth()` / `getHeight()` 返回尺寸。 +`advance(terrain, settings)` 推进一步,成功返回任意字段发生变化的网格数。 +它只借用地形,既不修改也不保留地形引用,暂不进行泥沙溶解或沉积。 + +`sample(x,z)` 返回 Result,成功 value 是独立的值记录,包含 `depth`、`velocityX`、 +`velocityZ`、`fluxRight`、`fluxLeft`、`fluxBottom`、`fluxTop`,不是内部缓冲区引用。 +越界或空状态查询明确失败。C++ 类型为 TerrainWaterField,内部使用 Pimpl; +禁止复制,可移动,移出后的对象为空且可 reset。脚本持有状态直到释放对象, +无需外部注册或跨域 Link。reset/advance 要求独占访问;空闲时才允许只读查询。 + +`eve.TerrainWaterSettings()` 提供以下有限浮点参数: + +- `spacingX=1`、`spacingZ=1`、`heightScale=1`、`waterScale=1`,必须为正。 +- `dt=0.05`,非负,0 表示不改变状态;没有隐式时钟或 RNG。 +- `precipitation=0.000004`、`evaporation=0.000004`,非负。 +- `flowAcceleration=-0.00049`,有符号值,对应原调用方的 flowRate*gravity。 + +参数是 Shader 接收到的值,不隐式重复 UI 转换。Pcg 调用方对 UI 雨量/蒸发 +乘 1e-5,对 flowRate 乘 1e-4 再乘 gravity,对 simulationScale 乘 0.001 +再与 terrain texel spacing 相乘。保留原公式的流量符号和 clamped 邻域。 + +计算明确分为完整通量场、速度与水深更新两个阶段,避免源 GPU 核同次 dispatch +内读取尚未完成的邻格通量。平均水深严格为零时速度定义为零。保留原源码最后 +以旧水深加降雨减蒸发覆盖输运水深的行为,因此它不是保守浅水模拟器。 +内部 double 中间计算、float 存储,固定阶段顺序;不承诺与原版存在竞争读写的 +GPU 核位级等价。任意输入错误或溢出均不提交已算出的局部通量/速度/水深。 + +```squirrel +local flow = eve.TerrainWaterField(); +assert(flow.reset(terrain.getWidth(), terrain.getHeight(), 0.0).ok); +local water = eve.TerrainWaterSettings(); +water.precipitation = 0.000008; +assert(flow.advance(terrain, water).ok); +local sample = flow.sample(0, 0); +assert(sample.ok); +print(sample.value.depth + "\n"); +``` + +水力侵蚀仍需接入泥沙、热侵蚀调度与诊断层;不能以此水流阶段宣称完整水力模拟 +已经移植,或替代渲染水面的波浪/反射/折射功能。 + +`eve.generateTerrainWaterFlowMap(target,source,settings)` 对应 Pcg 独立的旧版 +`WaterFlowMap.CreateWaterFlowMap`,不复用上述水流状态。`TerrainWaterFlowMapSettings` 字段为 +`dropletVolume`、`absorptionRate`、`smoothIterations`。它从每个非边缘 texel 投放水滴,按 X-major、 +Z-minor 顺序查找八邻域中严格更低的第一个点;没有低点时只抬高私有地形副本。每一步总是沉积完整 +absorptionRate,因此最后一步可以超过初始体积。完成后按原 `HeightMap.Flip` 交换 X/Z 轴,再执行 +顺序相关的原地四邻域平滑并钳制到 [0,1]。矩形输入要求目标尺寸为 source height × source width。 +输入无效、数值不再前进或溢出时目标保持不变;API 无随机数、隐式时间或保留引用。 + +`eve.generateTerrainVelocityFlowMap(target,source,iterations)` 对应同一 Pcg `HeightMap` 类型的另一项 +`FlowMap` 运算。它从每格 `0.0001` 水量开始,以源码固定 `TIME=0.2` 累积左、右、下、上四向通量, +限制单步总流出不超过当前水量,更新邻格流入后从最终通量计算二维速度幅值,并按整图 min/max 归一化; +范围小于 `1e-12` 时输出全零。目标必须同尺寸,可以与 source 为同一对象;负迭代或任何非有限中间值 +返回失败并保留目标。该 API 与 WaterFlowMap 水滴追踪及 `TerrainWaterField` 水力状态均为独立合同。 + +### 去除高度图台阶 + +`eve.analyzeTerrainTerraces(classes,source,settings)` 与 +`eve.removeTerrainTerraces(target,source,classes,settings)` 移植 Pcg 4.2.2 +`HeightmapTerraceRemover` 的两阶段工具。分类值为 0=Black、1=Red、2=Green、3=Blue,优先级与源码一致: +terrace、flat、mountain、slope terrace、black。边缘保持 Black。 + +`TerrainTerraceRemovalSettings` 暴露 `perlinScale`、`perlinStrength`、 +`slopeTerraceThreshold`、`flatThreshold`、`verticalGradientThreshold`、 +`minimumTerraceThreshold`、`maximumTerraceThreshold`、`excludeRed`、`excludeBlack`、 +`terrainWorkflow` 和 `noiseSeed`。处理顺序固定为:选中区域 3×3 平均、梯度衰减 Perlin 噪声、 +`1/2/1` 三向滤波、sigma=1 的 5×5 高斯模糊。`terrainWorkflow=true` 保留源码 +`0.00042/0.0035` 的噪声强度比例。EVEngine 使用具名 seed 的原生 Perlin 实现,保持确定性;Unity +`Mathf.PerlinNoise` 的私有排列不作为跨引擎逐像素合同。 + +两个调用都先完成验证与候选计算再发布。apply 允许 target 与 source 相同,但分类图必须是独立对象; +失败不会留下部分平滑结果,也不保留任何输入引用。 + +### HeightMap 曲率与坡向派生图 + +`eve.generateTerrainHeightmapCurvature(target,source,mode)` 精确对应 Pcg `HeightMap.CurvatureMap`,mode +沿原枚举为 0=Average、1=Horizontal、2=Vertical。它以归一化像素间距计算 dx/dy、dxx/dyy、dxy,按 +Pcg 水平/垂直曲率公式限制到 ±10000,再映射至 [0,1]。平坦或退化分母按源码得到中值 0.5。 + +`eve.generateTerrainHeightmapAspect(target,source,mode)` 对应 `HeightMap.Aspect`,mode 为 0=Aspect、 +1=Northerness、2=Easterness。完整坡向除以 360;北向性和东向性分别用 cosine/sine 映射到 [0,1]。 +实现保留源码自定义 Sign 和边缘 clamp 规则,包括平坦样本的 0.5 full-aspect 结果。 + +两项都要求至少 2×2 的有限同尺寸栅格,允许 target/source 别名,先读取完整旧快照并在成功后原子发布; +非法枚举、尺寸或数值失败不会修改目标。它们是 `HeightMap.cs` CPU 派生图,不等同于 shader 风格的 +`generateCurvatureMask` 径向模糊操作。 + +### HeightMap 邻域修复 + +`eve.filterTerrainHeightmapNeighborhood(target,source,radius,mode)` 对应 Pcg `HeightMap.DeNoise`、 +`GrowEdges` 与 `ShrinkEdges`,mode 为 0、1、2。DeNoise 只处理拥有完整邻域的内部像素,把离群值限制到 +邻居最小值与最大值之间;GrowEdges/ShrinkEdges 在边界跳过越界邻居,并把像素向邻域极值移动一半。 + +实现刻意保留 Pcg 的 X 优先、Y 次优先原地遍历语义:同一调用中靠后的像素会读取已经更新的靠前像素。 +target 可以与 source 相同;实现先复制输入快照,在全部验证成功后发布,因此非法半径、枚举、尺寸或非有限值 +不会留下部分结果。DeNoise 要求 radius 至少为 1。 + +`eve.smoothTerrainHeightmap(target,source,iterations)` 对应四邻域 `HeightMap.Smooth`,保留边缘 clamp、 +[0,1] clamp 和每次写回立即影响后续 X→Y 样本的行为。`eve.smoothTerrainHeightmapRadius(target,source,radius)` +对应缩放滑窗版本,像源码一样把 radius 至少提升到 5,并保留每列第一个完整窗口只计算而不写回的行为。 +`eve.convolveTerrainHeightmap(target,source,kernel)` 对应原地卷积;只有完整覆盖 kernel 的像素会改变,结果 +限制到 [0,1],近零 divisor 使用 1。为避免 Unity 方法对畸形数组的越界行为,原生 API 明确要求有限、 +奇数边长的正方形 kernel。 + +`eve.generateTerrainHeightmapSlope(target,source)` 精确对应 `HeightMap.SlopeMap`:使用归一化像素间距、 +边缘重复采样、源码固定 0.5 高度比例和 `g/sqrt(1+g²)` 映射。它与旧 `GetSlopeMap` 的前向差分不同; +后者在最后一行/列直接访问越界,因此没有作为安全公共 API 暴露。 + +`eve.quantizeTerrainHeightmap(target,source,divisor)` 对应标量 `HeightMap.Quantize(float)`,使用 Unity +`Mathf.Round` 的 midpoint-to-even 规则。除数必须有限且非零;别名安全,失败不修改 target。 + +### HeightMap 栅格代数 + +`eve.applyTerrainHeightmapScalarArithmetic(target,source,operand,operation,clampResult,min,max)` 和 +`eve.applyTerrainHeightmapRasterArithmetic(target,source,operand,operation,clampResult,min,max)` 覆盖 Pcg +的 Add、Subtract、Multiply、Divide 及全部 Clamped 重载;operation 依次为 0–3。栅格尺寸不同时,右操作数 +按 Pcg 的 `x/targetWidth`、`y/targetHeight` 坐标和源尺寸双线性采样。原生合同拒绝除零和非有限结果, +避免把 C# Infinity 写入后续地形管线;错误时 target 保持不变。 + +`eve.lerpTerrainHeightmap(target,source,values,mask)` 对应 `HeightMap.Lerp`,values 和 mask 可各自采用不同 +尺寸并按相同规则重采样;插值量按 `Mathf.Lerp` 限制到 [0,1]。三项均允许 target 与任一输入别名。 + +`eve.transformTerrainHeightmap(target,source,transform,parameter)` 覆盖基础 `HeightMap.Invert`、 +`Normalise`、`Power`、`Contrast`,transform 为 0–3。Normalise 对常量图保持原值;Power 的 parameter 是 +源码直接指数,不是 terrain effect 中的 `4-power`;Contrast 使用 `(h-0.5)*parameter+0.5` 且不隐式 clamp。 + +`eve.copyTerrainHeightmap(target,source,mode)` 对应 `HeightMap.Copy`,mode 为 0=AlwaysCopy、 +1=CopyIfLessThan、2=CopyIfGreaterThan;条件比较以 target 调用前的当前值为基准。异尺寸 source 使用 Pcg +归一化坐标重采样。`eve.copyTerrainHeightmapClamped(target,source,min,max)` 对应 CopyClamped,先采样再限制。 +两项都以 target 尺寸为输出权威,允许 source/target 别名,非法枚举或区间失败时不修改 target。 + +`eve.flipTerrainHeightmap(target,source)` 对应 Pcg `HeightMap.Flip` 的矩阵转置,会把 W×H 输出调整为 +H×W;它不是水平或垂直镜像。实现先复制 source 并构造完整候选,因而支持原地调用。 + +`eve.measureTerrainHeightmap(source,measure)` 统一提供当前样本的 Minimum、Maximum、Sum、Average、 +BaseLevel(measure 0–4)。Sum/Average 保留 Pcg X→Z 顺序的 float 累加;BaseLevel 从 0 开始扫描四条边, +因此全负边界仍返回 0。每次调用直接读取权威样本,不要求调用者维护 Unity 的 dirty 统计缓存。 + +`eve.quantizeTerrainHeightmapTerraces(target,source,startHeights,curves)` 对应 Pcg +`Quantize(float[],AnimationCurve[])`。startHeights 是 N×1 严格递增栅格;curves 是 W×N 栅格,每行以 +线性采样表示一条 Unity 曲线。区间仍从最高 terrace 向下搜索,因此公共边界归入较高层。输入高度必须由 +首个 start 到 1 完整覆盖;这把 Unity 的负索引异常转成原子 InvalidArgument。 + +`eve.measureTerrainHeightmapSlope(source,x,y,mode)` 暴露 Pcg 三个同名但不同单位的点查询:mode 0 是 +整数格点向 +X/+Z 的梯度长度;mode 1 是归一化坐标、0.9 texel 中心差并乘 10000 后限制到 [0,90]; +mode 2 是归一化坐标四向绝对高差平均再乘 400。整数模式要求右/下邻居存在,归一化模式要求坐标在 [0,1]。 + +`eve.fillTerrainHeightmap(target,value)` 对应 `SetHeight` 并将统一高度限制到 [0,1]; +`setTerrainHeightmapSafe` 把越界整数坐标限制到最近边界后写入。`setTerrainHeightmapRow` 的 row 固定 X、 +需要 target-height 长度的一维 strip;`setTerrainHeightmapColumn` 固定 Z、需要 target-width strip,与 Pcg +命名和数组方向一致。`resetTerrainHeightmap` 把对象清为 0×0。所有非 reset 写入均先验证并原子发布。 + +`eve.sampleTerrainHeightmapSafe(source,x,z)` 将整数坐标限制到最近边界,对应 `GetSafeHeight`。 +`sampleTerrainHeightmapNormalized(source,x,z)` 接受 [0,1] 坐标并严格采用 Pcg 的 `x*width,z*depth` +双线性规则;这与 EVEngine `sampleBilinear` 的像素坐标合同不同。`heightmap.hasData()` 验证正尺寸及完整 +样本存储,`heightmap.isPowerOfTwo()` 要求两个维度均为正二次幂。 + +Pcg 的 MinVal/MaxVal/SumVal、UpdateStats 和 dirty 标志是同一 Unity 编辑器缓存协议。EVEngine 的 +measure API 每次读取权威样本,因此不会暴露可能过期的统计缓存或手工 dirty 开关。元数据由带 +schema/version 的 TerrainAsset、TerrainFile 或上层资产定义拥有,不附着无结构字节到通用 Heightmap。 + + +### 泥沙与联合侵蚀 + +`heightmap.applyLegacyDistributedErosion(settings)` 对应 `HeightMap.Erode`:只扫描内部格点,若最大 +四向下降量位于阈值区间,就从中心移走其一半,并按所有正下降量的比例同步分配到四邻域。 +`TerrainLegacyDistributedErosionSettings` 提供 minimumThreshold、maximumThreshold 和 iterations。 + +`heightmap.applyLegacySteepestErosion(hardness, settings)` 对应活跃的 `HeightMap.ErodeThermal`:按 X 外层、 +Z 内层原地扫描,严格依次比较下、左、右、上邻居,将最大下降量的一半乘 `(1-hardness)` 后移给首个 +最陡邻居。hardness 可使用不同尺寸,并按 Pcg 的归一化宽深双线性规则采样。 +`TerrainLegacySteepestErosionSettings` 提供 iterations、talusMinimum 和 talusMaximum。 +两个调用都原子发布、使用显式迭代数,且不依赖隐式时钟或随机数。 + +`eve.TerrainLegacyHydraulicSettings()` 与 +`heightmap.applyLegacyHydraulic(sediment, hardness, rain, settings)` 对应旧版 +`HeightMap.ErodeHydraulic`。设置包含 `iterations`、正整数 `rainFrequency` 与 [0,1] +`sedimentDissolveRate`。每隔 rainFrequency 轮加入 rain;四向通量保留跨轮状态并使用固定 TIME=0.2; +更新水深后,按八邻域严格下降量与 `(1-hardness)` 搬运泥沙,地形限制到 [0,1],泥沙保留有符号值。 +该调用先快照输入并私下完成所有迭代,成功才同时发布地形与泥沙;无隐式时钟或随机数。 +它与下面的 GPU 来源 Water→Sediment→Thermal 管线及 `WaterFlowMap` 水滴累积图是三套独立合同。 + +`eve.TerrainSedimentSettings()` 提供 `effect=-1`、`depositRate=0.00004`、 +`bankDeposit=1`、`bedDeposit=5`。这些是实际计算系数,不是 Pcg UI 的缩放前数值。 + +- `heightmap.applySediment(sediment, velocityX, velocityZ, waterSettings, sedimentSettings)` + 返回结构化 Result,成功值为两个输出中发生变化的格子数。 +- `waterField.advanceHydraulic(heights, sediment, waterSettings, sedimentSettings, thermalSettings, iterations)` + 顺序执行水流、泥沙、热侵蚀,并返回三个所有者中发生变化的格子数。 + 任一轮失败均保留调用前的地形、泥沙和水流状态。 + +所有栅格必须同尺寸且有限,地形非负;地形与泥沙必须是不同对象。 +调用同步执行、无回调,调用方负责线程串行访问;不保留传入对象的引用。 +独立泥沙操作允许速度栅格与输出别名,始终读取调用前快照。 +`waterSettings.dt` 控制沉积和回溯;热侵蚀使用独立的 `thermalSettings.dt`, +若匹配 Pcg 调度,应由调用方先乘水流时间步。 + +该泥沙算子保留参考源码的特殊权重、混合坐标索引与叠加行为, +不是标准双线性输运:无速度且无沉积时非负泥沙每轮翻倍,即使 dt 为零。 +源码硬度固定为零,溶解项无效,因此不暴露不起作用的溶解系数。 +平坦地形的未定义归一化明确取坡度零,越界整数采样取零。 +联合调度始终读取最新完整阶段并发布最后一轮,未复制参考 C# 固定输出缓冲区 +及重置热侵蚀缓冲索引的问题;不承诺与原 Unity GPU 执行逐位相等。 + + +`waterField.exportChannel(target, channel)` 将一个完整水流通道原子写入同尺寸、 +有限的目标栅格,返回变化格子数的 Result;不调整尺寸,不保留引用,失败不修改目标。 +通道编号为 0 深度、1 X 速度、2 Z 速度、3 右向通量、4 左向通量、5 下向通量、6 上向通量。 +输出保留原始符号和单位,不取绝对值、不归一化。调用需独占目标且禁止并发修改水流场。 +Pcg 的 SimpleHeightBlend 读取红通道,因此原 WaterVelocity 遮罩对应 1, +WaterFlux 对应 3,而不是速度长度或总通量。导出栅格可直接传给现有 +`transformMask` / `blendMask` 操作;完整 PaintContext UV 与原始地形混合仍需上层组合。 + + +### 空间笔刷混合 + +`eve.TerrainBrushBlendSettings()` 配置独立的地形与笔刷仿射 UV。 +地形字段为 `heightXX`、`heightXZ`、`heightZX`、`heightZZ`、 +`heightOffsetX`、`heightOffsetZ`;笔刷字段为 `brushXX`、`brushXZ`、 +`brushZX`、`brushZZ`、`brushOffsetX`、`brushOffsetZ`。 +两个矩阵默认单位矩阵,偏移默认零,`strength=1`。 +坐标公式为 `(XX*u+XZ*v+offsetX, ZX*u+ZZ*v+offsetZ)`,输入 UV 是目标像素中心。 + +`target.blendBrush(oldHeights, newHeights, brush, settings)` 返回变化格子数的 Result。 +三个输入可独立分辨率,采用 Clamp/Bilinear 像素中心采样;笔刷 UV 超出闭区间 +[0,1] 时输出采样后的旧地形,区间内输出 `lerp(old,new,strength*brush)`。 +权重不钳制,支持参考公式的外插;输入已是原生标量,不做 Unity 高度打包。 +所有栅格和系数必须有限,溢出不提交;目标可与输入别名,读取调用前快照。 +调用同步、无回调、不保留引用,调用方需独占目标并保证输入不被并发写入。 +这是 SimpleHeightBlend 的空间组合步骤,侵蚀遮罩的反转和后续滤镜仍需显式组合。 + + +### 强度滤镜与侵蚀遮罩组合 + +`target.applyStrength(source, curve, mode, strength, invert)` 对同尺寸输入先查一行曲线, +再按 invert 取 `1-curveValue`,然后计算模式结果,最后从输入向结果插值。 +mode 为 0 替换、1 最大值、2 最小值、3 相加、4 相减。参考 shader 将 0 命名为 +Multiply,但实际公式是替换曲线值,不是相乘。strength 必须在 [0,1];结果不钳制。 + +`target.generateErosionMask(oldHeights, erosion, brush, spatial, curve, mode, strength, userInvert)` +先执行空间笔刷混合,再执行强度滤镜,按 Pcg 侵蚀分支将滤镜反转设为 `!userInvert`。 +erosion 是已计算的泥沙或通过 exportChannel 导出的右向通量/X 速度;此调用不推进模拟。 +spatial 为 TerrainBrushBlendSettings,曲线为有限非空一行栅格。 +两步整体原子提交,后一步失败也不会留下前一步结果;返回变化格子数的 Result。 +输入与目标允许别名,使用调用前快照;同步独占目标、输入不可并发修改,不保留引用或调用回调。 +该组合采用原生标量,仍需调用方根据世界/地形范围提供 UV 映射和模拟参数。 + + +### Grow/Shrink 遮罩 + +`target.growShrinkMask(source, curve, distance)` 返回变化格子数的 Result。 +source 与 target 同尺寸且有限,curve 为有限非空一行强度曲线。 +distance 是世界距离除以操作范围所得的 UV 半径,正值增长、负值收缩; +`2*abs(distance)*sourceWidth` 必须不超过 int 最大值以表达采样索引。 +两个方向步长均为 `1/sourceWidth`,X 外层、Z 内层遍历;每个候选值先按径向 +smoothstep 与当前累积值混合,再做最大/最小选择,因此不是普通形态学滤波。 +越界候选跳过,采样使用 Clamp/Bilinear 像素中心;最终经过 curve,距离零也不省略曲线。 +整数索引和 double 偏移明确定义采样序列,不复现 shader 浮点累加误差。 +最坏采样成本随半径增长;实现跳过确定越界区间,但不改变区间内的访问顺序。 +失败保留目标,别名输入读取快照。同步独占目标、不可并发修改输入,无回调或引用留存。 + + +### 曲率遮罩 + +`eve.TerrainCurvatureSettings()`:`radius=0.001` 为 UV 模糊半径,`worldUnits=-400` +缩放有符号高差,`intensity=0.7` 为正幂指数,`steps=32` 为每方向样本数, +`directions=16` 为方向数。半径非负,采样数为正,乘积必须小于 INT_MAX。 + +`target.generateCurvatureMask(input, heights, curve, settings, mode)` 返回变化格子数的 Result。 +目标与 input 同尺寸,heights 可独立分辨率,curve 为有限非空一行强度曲线。 +中心及各径向样本平均后,计算 `clamp(abs(pow((origin-blur)*worldUnits,intensity)),0,1)`, +再查曲线并按 mode 混合:0 相乘、1 最大、2 特殊 Smaller、3 相加、4 相减。 +Smaller 的原公式是 `filter < 1-input ? filter : input`,可能增加输入,不能当作普通 min。 +该 shader 不使用 strength 或 invert。负底数的非整数幂显式失败;没有提前对底数取绝对值。 +正幂溢出的无穷幅值按曲率饱和公式变为 1,最终标量输出溢出则不提交。 +径向循环以整数索引固定样本数,不承诺原 shader 浮点循环的逐位等价。 +所有栅格和系数必须有限。输出别名读快照,失败原子保留目标;同步独占目标, +输入禁止并发修改,不保留引用、无回调。径向模糊设计的 MIT 归属与许可保留在实现中。 + + +### 凹凸遮罩 + +`eve.TerrainConcavitySettings()` 提供 `featureSize=10`(高度纹素距离)与 +`concavity=1`(有符号凹凸系数)。featureSize 为正有限且可表示为 uint32; +邻域偏移取整数截断值,边缘衰减使用未截断值。 + +`target.generateConcavityMask(input, heights, curve, settings, mode)` 返回变化格子数的 Result。 +目标与 input 同尺寸,heights 的坐标映射在两个轴上都使用 heightWidth/inputWidth, +映射坐标须可表示为 uint32。曲线是有限非空一行数组,读取 +`floor(clamp(value,0,1)*(curveWidth-1))`,不做双线性曲线采样。 +mode 为 0 相乘、1 最大、2 最小、3 相加、4 相减,未加入 strength 或 invert。 +原计算的无符号邻域运算及包含 width/height 的上界保持;越界纹素读零, +零长度梯度定义为零。边缘衰减后再倍增、钳制并查曲线。 +使用 double 中间值,不承诺原 GPU 逐位一致。所有输入有限,失败不提交目标; +别名读调用前快照,同步独占目标,无回调或引用留存,输入不得并发修改。 + + +### 图像遮罩 + +`eve.TerrainImageMaskSettings()` 提供 `offsetX=0`、`offsetZ=0`、`scaleX=1`、 +`scaleZ=1`、`rotation=0`(弧度)、`tiling=false`,以及选择颜色 `red=1`、 +`green=1`、`blue=1` 和 `accuracy=0`。缩放不能为零,accuracy 在 [0,1],所有值有限。 + +`target.generateImageMask(input, red, green, blue, alpha, curve, settings, filter, mode)` +接收四个同尺寸有限 RGBA 标量平面,独立于目标分辨率;目标和 input 同尺寸。 +返回变化格子数的 Result,目标可与任意输入别名,所有失败均不提交。 +filter 为 0 RGB 最大通道(不是亮度)、1 Lab 颜色选择、2 红、3 绿、4 蓝、5 Alpha。 +mode 为 0 相乘、1 最大、2 最小、3 相加、4 相减,不额外使用 strength/invert。 + +图像在目标像素中心做中心偏移、缩放和旋转后双线性采样;平铺保留源码负整数 +映射到 1 的约定。不平铺时范围外筛选值为零,再经过一行 curve,因此最终未必为零。 +颜色选择保留源码 sRGB→XYZ→Lab 与 CIE76 距离,距离钳制到 [0,100]; +匹配条件严格为 `difference < (1-accuracy)*100`,命中后得到 +`1-difference/100*accuracy`。accuracy=1 连相同颜色也不命中。 +传入色彩数值不做隐式解码或额外色彩空间转换,Alpha 不参与 Lab 距离。 +调用同步独占目标,输入不得并发修改;无回调和引用留存,图像解码/GPU 资源仍由调用方管理。 + + +`eve.generateTerrainImageMaskFromImage(target, input, imageData, curve, settings, filter, mode)` +是完整宿主的 ImageData 适配入口,返回变化格子数的 Result。目标可与 input/curve 别名。 +它按 ImageData 当前像素格式读取 RGBA 浮点平面,再调用相同图像遮罩核心; +不额外做 sRGB 转换或垂直翻转。输入须为可读取且存储完整的图像,非有限像素、 +无效设置和尺寸不匹配都不会修改目标。同步独占目标,图像及输入不得并发修改; +不保留 ImageData、像素指针或 GPU 资源。完整宿主需要 image 模块,核心裁剪配置 +继续使用 generateImageMask 的四平面接口,不隐式提供图像解码回退。 + +### 碰撞遮罩栈 + +`eve.TerrainCollisionMaskStack()` 接收调用方已经从场景、树或对象缓存烘焙出的有限标量栅格。 +`addLayer(mask,type,active,invert)` 深拷贝一层;type 为 0=RadiusTree、1=RadiusTag、 +2=LayerGameObject、3=LayerTree。`getLayerCount()` 包含禁用层,`clear()` 清空所有层。 + +`apply(target,input,curve,mode)` 对每个输出像素按 Clamp/Bilinear 重采样所有 active 层,从白色开始依次 +取最小值,随后应用一行强度曲线和 ImageMask blend。Radius 类型在 `invert=true` 时反转;Pcg 的 +Layer baked texture 以白色表示占用,因此 Layer 类型使用相反的 invert 约定。目标与 input 尺寸一致, +各层可使用不同分辨率;输入会被快照,允许 target alias。非法栅格、类型、模式或有限范围失败时不 +修改 target。栈不持有场景对象或渲染缓存;几何查询和缓存失效由场景/provider 负责。 + +### 多边形笔刷遮罩 + +`eve.TerrainPolygonMask()` 按顺序拥有控制节点。`addNode(worldX,worldZ,radius,strength)` 复制有限世界 +坐标、正半径和 strength 元数据;`getNodeCount()` 返回节点数,`clear()` 清空。`rasterize(target,brush, +originX,originZ,spacingX,spacingZ,type)` 将节点绘制到目标网格,type 为 0=Open、1=Closed。Open 只绘制 +节点笔刷;Closed 在至少三个节点时先用 Pcg `SH_PolygonFill.shader` 相同的奇偶射线规则填白多边形, +再按节点顺序叠加笔刷。每次叠加钳制上限到 1,并把小于 0.25 的结果清零。笔刷红通道由 Heightmap +标量表示,节点方形范围外明确为零,范围内使用 Clamp/Bilinear 纹理中心采样。 + +Pcg 4.2.2 的 `PolyMask.cs` 会把 `PolyMaskNode.Strength` 传给 `ApplyBrushStroke`,但该函数和 +`SH_AdditiveBrush.shader` 都不设置或读取 strength;EVEngine 保留字段以便资产转换,同时按该版本 +可观察行为不以它缩放笔刷。地形射线吸附、编辑器选择、可视化网格和缓存失效属于编辑器/provider, +核心不持有场景对象。输出先在私有缓冲完成后一次发布,非法网格、类型、非有限值或间距失败不修改目标。 + +### WorldBiome baked-mask cache + +`eve.TerrainBakedMaskCache()` 以 `(terrainId,maskGuid)` 为稳定复合键拥有已烘焙有限 Heightmap。 +`store` 深拷贝或原子替换条目并标记 fresh;`markDirty(maskGuid)` 使所有地形上的同 GUID 条目显式 stale; +`copyMask` 仅导出 fresh 条目,并按目标分辨率使用 Clamp/Bilinear 采样。missing 与 dirty 都返回结构化 +NotFound,且保留旧输出。provider 完成世界图规则重算后再次 `store` 即重建条目。`erase` 删除精确复合键, +`clear` 清空全部缓存,`getEntryCount` 包含 dirty 条目。 + +这对应 Pcg `GetWorldBiomeMask`/`BakedMaskCache` 的缓存、跨分辨率 Graphics.Blit 与 GUID 失效边界。 +世界图规则求值和磁盘资产读取属于上层 provider;缓存不持有场景、Terrain、GPU texture、回调或文件句柄。 +provider 先销毁时 dirty/missing 明确失败,cache 先销毁时只释放其值副本;热重载通过 markDirty 后重新 store, +不会把旧图无声当作当前结果。 + +`ProcgenHeightmap.applyGlobalSpawnerMask(input,source,curve,settings,mode)` 对应 ImageMask 的 +`GlobalSpawnerMaskStack` 分支。source 是被引用 Spawner 的完整蒙版栈输出;接口把标量复制为通道语义, +强制选取 Red,再复用 ImageMask 的 UV 偏移、缩放、旋转、tiling、强度曲线和五种 blend。settings.filter +不会改变标量结果,其余字段按 `generateImageMask` 验证。调用只借用 source,不持有另一个 Spawner 或 +RenderTexture;目标与 input 可别名,失败时不发布部分结果。 + +### NoiseMask + +`ProcgenHeightmap.generateNoiseMask(input,curve,settings,type,mode)` 生成 NoiseMask 并立即按强度曲线与 +五种 ImageMask blend 合并。type 为 0=Perlin、1=Value、2=Billow、3=Ridge、4=Voronoi。 +`TerrainNoiseMaskSettings` 提供 `translationX`、`translationZ`、`scaleX`、`scaleZ`、弧度 `rotation`、 +`octaves`、`amplitude`、`frequency`、`persistence`、`lacunarity`、`warpIterations`、`warpStrength`、 +`warpOffsetX`、`warpOffsetZ` 与显式 `seed`。 +octaves 和 warpIterations 支持 Pcg fBm 风格的分数最后一步,范围为 `[0,16]`;默认值来自包内 +PcgNoiseSettings/FbmFractalType。输出按固定扫描与固定 hash 流确定,不读取帧时钟或全局随机状态。 + +本实现复用 EVEngine `NoiseField` 的 CPU hash/Perlin 基础,因此保留 Pcg 的五类形态、TRS、fBm 与 warp +参数语义,但尚不声明与 Unity HLSL `sin` hash 逐像素相同;跨后端比较采用数值与形态容限。所有输入 +先验证并在私有缓冲计算,非法枚举、零 scale、非有限参数或溢出不修改目标。 + +### Smooth ImageMask + +`ProcgenHeightmap.smoothMask(input,verticality,blurRadius)` 对应 ImageMask 的 Smooth 分支。它按 +`SmoothHeight.shader` 固定七对权重先水平再垂直处理;`verticality=-1` 只降低,`0` 取加权平均,`1` +只抬高。垂直 pass 保留原 shader 使用 X texel size 的行为,非方形栅格也按该约定采样。shader 虽读取 +HeightTransformTex,但最终返回值没有使用它,因此该入口不接受强度曲线或额外 blend。 + + +### 世界坐标到笔刷 UV + +`eve.TerrainSampleGrid()` 描述高度纹理的样本中心,`width=1`、`height=1`; +`setOrigin(x,z)` 设置首样本世界坐标,`setSpacing(x,z)` 设置正采样间距,默认均为 1。 + +`spatial.configureWorld(world, contextWidth, contextHeight, heightGrid)` 将世界配置转换为 +TerrainBrushBlendSettings 的两个 UV 矩阵,返回变化系数数目的 Result。 +world 是 TerrainStampSettings:其 grid 描述目标首样本与间距,center/size/rotation +描述笔刷世界矩形;高度操作、amplitude/baseHeight/blendStrength 不参与映射。 +heightGrid 描述传给 blendBrush 的旧高度纹理,spatial.strength 保持不变。 + +转换显式处理半像素偏移,使相同网格生成单位高度 UV 映射;旋转以 X/Z 平面逆映射 +将世界位置变为笔刷坐标。C++ 先在 double 中计算原点差,再转换为有限 float 系数; +Squirrel 当前是 float VM,传入大绝对坐标前需调用方完成世界原点偏移,不能恢复已丢失精度。 +尺寸、间距和笔刷范围必须为正,坐标有限;跨度或系数溢出时不修改 spatial。 +调用同步独占 spatial,不保留输入引用,也不会移动地形或修改任何栅格。 + + +### 地形生成会话 + +`eve.TerrainGenerationSession()` 创建独立、初始为空的地形会话。支持记录原生印章和下述七种标量地形效果。 +`reset(baseline)` 复制有限基线并清空历史;`stamp(stamp, settings, operation, localMask, globalMask)` +复制印章、设置和两个遮罩为命令输入,成功后追加记录。操作编号与 applyStamp 相同。 +`copyTerrain(output)` 将权威地形复制到同尺寸有限栅格;修改这个副本不会改变会话。 + +`undo()`、`redo()` 改变已应用历史前缀并从基线回放,`replay()` 重算当前前缀。 +`setOperationEnabled(index, enabled)` 切换命令并重算;`operationEnabled(index)` 返回布尔值 Result。 +`getOperationCount()` 包含待重做的后缀,`getAppliedCount()` 包含前缀中的禁用命令。 +index 只是会话内索引,不是跨重置/分支的稳定 ID。撤销后成功追加新操作会截断重做后缀, +追加失败不会截断;切换早期命令导致后续溢出时,历史和地形都保持原样。 + +`setAccess(access)` 返回 void Result,0 为 Editable、1 为 Locked;`getAccess()` 返回当前策略。 +锁定拒绝 reset、stamp、undo、redo、replay 和命令开关,允许读取快照和解锁。 +其余修改操作返回变化地形样本数 Result,reset 返回基线样本数,失败返回结构化错误。 +所有状态由会话单独持有,公共读取为副本;调用在所属线程串行执行,无外部引用留存或回调。 +撤销/回放复制候选状态后完整重算,内存和耗时随历史输入大小增长;同一构建下用固定输入顺序 +保证浮点重算一致性,不依赖隐式时钟/RNG。不承诺跨平台逐位一致。 +这不是现有分块点集 RuntimeGeneration 调度器的替代,也不包含后台线程或其他操作种类。 + +`snapshotJson()` 写出严格的 `eve.procgen.terrain-generation-session` schema version 1,拥有 baseline、 +全部命令输入与设置、enabled 标志、已应用 cursor 和访问策略。`restoreJson(json)` 拒绝未知根字段、 +版本、非法枚举、非有限数值、尺寸/数量越界、截断或尾随 payload;它先以全部命令启用验证完整历史, +再按保存的 enabled/cursor 重建最终地形、泥沙和七个水通道,成功后一次交换。Pending 会话和 Locked +目标拒绝恢复;快照不会保存临时 replay 候选,恢复后的调度状态为 Idle。 + +调用方可用 `beginReplay()` 启动分步重放,再用 `stepReplay(maxOperations)` 按正数命令预算推进。 +状态枚举为 0=Idle、1=Pending、2=Completed、3=Cancelled、4=Failed;`getReplayStatus()` 和 +`getReplayCompletedOperations()` 可轮询当前生命周期与已访问命令数。Pending 阶段只修改私有候选, +`copyTerrain`、`copySediment` 和 `exportWater` 继续返回最后一次已发布状态;完成全部命令时三类数据 +一次发布。`cancelReplay()` 丢弃 Pending 候选,失败也不修改权威状态。Pending 时拒绝历史编辑、访问 +策略切换和第二次 begin;取消或终态之后可以重新开始。这提供与 Pcg coroutine 对应的显式可观察 +调度边界,但由所属线程逐步驱动,不创建后台 worker,也不跨线程调用脚本。 + + +地形会话还提供 `contrast(mask,strength,featureSize)`、`smooth(mask,settings)`、 +`ridges(mask,settings)`、`terrace(mask,settings)`、`power(mask,power)`、 +`heightCurve(mask,curve,minimum,maximum)`、`heightMix(localMask,globalMask,settings)`。 + +`eve.bakeTerrainCurveTexture(output, curve)` 将单行 Heightmap 曲线 LUT 写入单行 ImageData, +对应 Pcg `TerrainToolsUtility.AnimationCurveToRenderTexture`。采样坐标为 `i / outputWidth`; +为保持源实现行为,像素 0 保持透明黑,但返回范围从曲线在 0 的值开始计算。成功 Result 的 +`value` 包含 `minimum`、`maximum` 和 `writtenPixels`。输入必须是非空单行图像与有限单行曲线; +失败不会改变输出图像。 +这些接口使用对应的 Terrain*Settings 和原生效果公式,复制栅格、曲线与设置作为历史输入; +成功返回变化地形样本数 Result。所有命令共用锁定、禁用、撤销、重做和原子回放契约。 +修改调用方的遮罩或曲线不影响已有命令。混合历史重算失败时也不修改命令开关和当前地形。 +会话快照记录足以重建水流/泥沙状态的完整命令输入,但仍不记录异步资源操作。 + + +### TerrainGenerationSession 侵蚀历史 + +`reset(baseline)` 现在同时初始化零泥沙、零水深、零速度和零通量。标量地形命令保留 +已有模拟状态;热侵蚀和水力侵蚀使用历史中前一步的状态。所有输出与历史一起原子发布, +撤销、重做、禁用操作和回放覆盖全部通道。共享的内部候选结果仅为不可变快照,没有对外 +可写别名。调用者仍须在所属线程串行访问;时间步长和迭代次数显式传入。 + +- `TerrainGenerationSession.resetSimulation(depth)`:记录可撤销的模拟重置,清零泥沙、 + 通量和速度,将水深设为有限非负值;地形不变。需要一次全新侵蚀时先调用它。 +- `TerrainGenerationSession.thermal(settings)`:记录热侵蚀,累积泥沙,保留水流状态。 +- `TerrainGenerationSession.hydraulic(water, sediment, thermal, iterations)`:记录完整水流、 + 泥沙、热侵蚀迭代;复制设置,延续已有模拟状态,不隐式应用 Unity UI 单位换算。 +- `TerrainGenerationSession.copySediment(output)`:复制当前有符号泥沙到匹配尺寸的有限高度图。 +- `TerrainGenerationSession.exportWater(output, channel)`:复制原始水流标量通道;整数通道 + 0=Depth、1=VelocityX、2=VelocityZ、3=FluxRight、4=FluxLeft、5=FluxBottom、6=FluxTop。 + +新增修改方法均返回 `Result` 的**地形变化样本数**;模拟状态改变但地形不变时可为 0。 +输出方法返回输出变化样本数,锁定时仍可读;修改方法在锁定时拒绝执行。 +非法输入、后续命令失败或分配异常不会提交部分模拟状态或截断重做历史。 +会话 reset 清空历史;resetSimulation 是历史中的命令,两者语义不同。 +该实现沿用前文记录的原生侵蚀内核约定,不复刻源 C# 包装层固定输出缓冲索引错误, +不声明与未定义的 GPU 调度行为逐位一致。会话仍无异步世界创建或植被资源历史;分步重放 +只调度本会话已拥有的地形/侵蚀命令。 + + +### TerrainDetailLayer 整数草地密度 + +`TerrainDetailLayer` 是独立的整数细节密度所有者,不是 `Grid2D` 的语义编号层。 +一个实例对应一张完整对齐的地形细节图,不持有资源注册表或图形对象。 + +- `TerrainDetailLayer.reset(width, height, count)`:初始化正尺寸与非负整数数量,返回单元数。 +- `TerrainDetailLayer.getWidth()`、`TerrainDetailLayer.getHeight()`:查询尺寸,空实例返回 0。 +- `TerrainDetailLayer.sample(x, z)`:返回 `Result` 的数量副本。 +- `TerrainDetailLayer.apply(fitness, settings, mode, seed)`:从匹配尺寸的有限 Heightmap 生成密度, + 返回变化单元数。mode 为 0=Replace、1=Add、2=Remove;seed 为显式有符号 32 位随机种子。 +- `TerrainDetailSettings.minimumFitness`:严格适应度下限,默认 0.5,范围 [0,1]。 +- `TerrainDetailSettings.fadeStart`:低于此值按适应度概率稀疏,默认 0.6,范围 [0,1]。 +- `TerrainDetailSettings.density`:规则密度乘全局密度后的有效值,默认 16,有限非负且小于 INT_MAX。 +- `TerrainDetailSettings.namespaceId`:稳定资源层身份;零为兼容默认层,非零用于同一 terrain 上独立保存和清理多个 detail prototype。 +- `TerrainDetailPlacementSettings.densityNamespaceId`:选择要导出为实例的密度资源层;`namespaceId` 继续定义输出点及其 `spawnNamespace` 来源身份。 + +Replace 先清零目标资源层;Add/Remove 保留未通过适应度或随机门槛的单元。通过的单元按 +`InverseLerp(minimumFitness,1,fitness)*density` 增减,然后钳制到 [0,density], +按最接近的偶数舍入。这意味着 Add 也可能降低原先超过目标密度的数量,这是源码行为。 +随机稀疏采用源码 XorshiftPlus(种子 0 按 1 处理,负种子按 uint32 位模式),X 外层、 +Z 内层遍历,只在适应度位于淡出带时抽样。每次 apply 重建独立 detail-thinning 流; +同构建、相同整层输入和种子可重复,不承诺分割区域后仍与单次完整层调用一致。 +所有输入先验证,候选数量计算完再提交;失败或分配异常不改变原层。 +调用者在所属线程串行访问,不保留借用,也不提供可写数量视图。 +资源层由稳定 namespace 拥有;实际草叶生成和渲染通过下述 PointSet 导出及 graphics 接口完成。 + + +### 草地密度到 PointSet + +`eve.exportTerrainDetailPoints(output, layer, heights, settings)` 把 TerrainDetailLayer 的 +每个整数数量展开为现有 PointSet,成功返回发射点数,失败保持原 output 不变。 +高度图可以与密度图分辨率不同,端点覆盖同一世界矩形,双线性取样后乘高度比例。 +输出使用现有 `asset` 字符串属性,并记录 `detailCell`(行优先单元编号)和 +`detailOrdinal`(单元内序号)。资源引用只传递给下游,不在此加载资产。 + +`TerrainDetailPlacementSettings` 字段: + +- `originX`、`originZ`:世界矩形起点,默认 0。 +- `width`、`depth`:正的世界宽度、深度,默认 1。 +- `heightScale`:有限高度比例,默认 1。 +- `minimumScale`、`maximumScale`:正的实例缩放范围,默认均为 1。 +- `seed`:位置、旋转、缩放三个独立命名随机流的显式 32 位种子,默认 1。 +- `namespaceId`:非零实例身份命名空间,默认 1;实际场景须按资源/瓦片分配不同值。 +- `maxPoints`:显式输出预算,默认 1000000,允许 0。超限拒绝整个输出,不截断。 +- `asset`:非空资源引用,必须由调用者指定。 + +ID 来自命名空间、单元和单元内序号,与总密度及遍历行号无关。修改其他单元不会 +移动或重编号现有点;改变网格尺寸或命名空间改变身份范围。零 ID 保留,极端命名空间 +映射为零时明确失败,调用者应改用其他命名空间。位置采用单元内原生随机分布, +朝向使用 Y 轴角度、法线向上;这部分替代 Unity 内部实例化,不宣称 Unity 位置一致。 +原输入只在本次调用借用,输出在私有 PointSet 完成后一次移动发布;没有场景对象 +所有权或回调。调用者在所属线程串行访问所有输入输出。 + + +### PointSet 草地渲染接入 + +`eve.bakeTerrainGrass(field, points, asset, width, height)` 是完整宿主适配器:按 `asset` +属性精确选择 PointSet 中的实例,保留其根位置与独立横向/纵向缩放,上传到既有 GrassField, +返回 `Result` 的上传数量。无匹配实例时清空已发布网格。非空资源选择器、有限 +根位置、正宽高、非零稳定 ID 以及大于 0.001 的横向/纵向缩放是前置条件;非法参数不改变 +现有可见草地。X/Z 缩放必须相同;Y 缩放独立,不再丢失草地宽高差异。 + +内置路径采用 GrassField 四帧草地贴图;图像路径由调用者显式上传已解码资源,asset +只做分组选择。64 位身份留在 PointSet;未染色实例折叠为可精确存入 float 的 24 位 +动画相位键,染色实例把 RGB 量化为 RGB8 并由局部根坐标生成动画相位,因此整体模型 +变换不会改变风相位。这不是场景对象身份的替代。法线和 yaw 不参与当前向上 billboard 的构建。 + +C++ `GrassField::bakePoints(points,width,height)` 接受已有 `grass::Point` 数组,不再 +进行 Poisson 重新采样。CPU 验证/网格构建及 GPU 创建在发布字段前完成;失败保持 +原字段。新增 GPU 对象按现有约定由 Graphics 管理到其析构,包括中断上传产生的对象。 +调用必须发生在 Graphics 所属线程、draw/capture 回调之外;不保留输入数组或 PointSet。 +此适配器需要完整 graphics provider,核心裁剪构建只包含密度与 PointSet 算法。 + + +`eve.bakeTerrainGrassImage(field, points, asset, width, height, image)` 使用一张已有 +RGBA8 ImageData 替代内置动画 atlas。实例分组选取和失败原子性与 bakeTerrainGrass +相同,图像必须具有完整 RGBA8 存储;包括空点组也先校验图像。上传保留 RGB/alpha, +不合成帧、不使用 RGB 推断透明度,采用单帧 UV 和中性白色/灰色明暗调制。 +原图仅在上传期间借用,GPU 纹理由 Graphics 拥有。C++ 对应 +`GrassField::bakeTexturedPoints(points,width,height,image)`,与内置路径共享同一发布实现。 +该图像 billboard 支持类型化风参数和健康/枯萎颜色;颜色 alpha 仍由纹理所有。 +`eve.bakeTerrainGrassFoliage(field,points,asset,width,height,albedo,normal,mask,settings)` 提供 +Pcg/PW 静态植被材质路径。三张图必须是同尺寸完整 RGBA8;遮罩通道为 metallic、occlusion、 +thickness、smoothness。`GrassFoliageSettings` 控制线性色调、alpha cutoff、normal strength、相机距离 +淡出和高度雪覆盖。EVEngine 使用自身方向光、环境光与级联阴影合同,未复制 Unity Standard 内部实现。 +全部 DetailRenderMode 仍不属于此适配器。 + + +独立宽高参数:`TerrainDetailPlacementSettings.minimumWidth`、`maximumWidth`、 +`minimumHeight`、`maximumHeight` 默认均为 1,都是正有限乘数,最大值不得小于最小值。 +分别用 width/height 命名随机流选择,与已有 minimumScale/maximumScale 统一比例相乘; +输出 X/Z 为横向比例、Y 为纵向比例。修改宽高范围不改变位置、朝向、ID 或其他单元。 +乘积不能用正 float 表示时拒绝完整输出。实际物理宽高还要乘上传时传入的 billboard 宽高。 + +颜色参数 `healthyR`、`healthyG`、`healthyB`、`healthyA`、`dryR`、`dryG`、`dryB`、`dryA` +均为线性 [0,1] 端点;`noiseSpread` 为 [0,1], +`noiseSeed` 为显式有符号 32 位种子。每个点按世界矩形内的归一化坐标采样确定性的平滑 +格点噪声,再在 dry 与 healthy 之间插值。`noiseSpread=0` 固定取中点。该噪声是对 Unity +Terrain 内部不可见实现的原生重建,保证 EVEngine 内重放稳定,不声明与 Unity 数值逐点一致。 +颜色配置不参与位置、缩放或稳定 ID;任何非法端点或 spread 都在发布前拒绝并保留旧输出。 + +### 地形树木放置 + +`eve.exportTerrainTreePoints(output, fitness, heights, settings)` 将一张归一化适应度图与独立 +分辨率的高度图展开为带资源属性的 PointSet。它按 `spacing / spawnDensity` 扫描世界矩形, +并使用 Pcg XorshiftPlus 相同的条件抽样顺序执行 `failureRate`、`jitterPercent`、 +`minimumFitness` 羽化、缩放随机值、Y 偏移和 0..360 度朝向。成功返回实例数;任何非法 +配置、超出 `maxPoints` 或属性发布错误都保留旧 output。 + +`TerrainTreePlacementSettings` 包含以下脚本字段:`originX`、`originZ`、`width`、`depth`、 +`heightScale`、`spacing`、`spawnDensity`、`jitterPercent`、`failureRate`、`minimumFitness`、 +`snapToTerrain`、`seaLevel`、`customOffset`、`minimumYOffset`、`maximumYOffset`、 +`minimumWidth`、`maximumWidth`、`minimumHeight`、`maximumHeight`、`widthRandomPercentage`、 +`heightRandomPercentage`、`healthyR`、`healthyG`、`healthyB`、`healthyA`、`dryR`、`dryG`、 +`dryB`、`dryA`、`bendFactor`、`boundsRadius`、`seed`、`namespaceId`、`maxPoints` 和 `asset`。 + +`setScaleMode(mode)` / `getScaleMode()` 使用 0=Fixed、1=Fitness、2=Random、 +3=FitnessRandomized。`setYOffsetMode(mode)` / `getYOffsetMode()` 使用 0=TerrainHeight、 +1=SeaLevel、2=Custom。`snapToTerrain=true` 总是采用采样地形高度;关闭后才按 Y 模式选择 +地形、海平面或自定义基准,再叠加随机偏移。 + +输出点携带世界位置、独立宽高、yaw、适应度颜色、局部 bounds,以及 `asset`、 +`treeCandidate`、`bendFactor` 属性。稳定 ID 由 namespace 和扫描单元生成;修改外观、颜色 +或缩放范围不改变实例身份和位置。当前 `bendFactor` 是供树木材质/风适配器读取的下游 +元数据;调用者可按原型创建 `gfx.newTreeWindShader()`、配置 `eve.TreeWindProfile`,再把同一 +shader 赋给该原型的 Renderable3D。显式适配避免普通 Renderable3D 猜测资源类型或引入第二份 +原型状态。 + +`eve.removeTerrainTreePoints(output, input, fitness, settings)` 实现 Pcg Remove:仅处理 +`asset` 匹配且位于配置矩形内的点,在当前 fitness **严格大于** `minimumFitness` 时移除, +并完整保留其他资源、矩形外点、行顺序和属性。output 可以与 input 是同一 PointSet。 + +`TerrainTreeRescaleSettings` 与 `eve.rescaleTerrainTreePoints(output, input, settings)` 实现 +原型刷新。字段 `previousMinimumWidth`、`previousMaximumWidth`、`previousMinimumHeight`、 +`previousMaximumHeight` 描述上次生成区间;`minimumWidth`、`maximumWidth`、`minimumHeight`、 +`maximumHeight`、`bendFactor`、`boundsRadius`、`asset` 描述新原型。它也提供 +`setScaleMode()` / `getScaleMode()`。Fixed 直接采用新最小值;其他模式按旧区间 +InverseLerp 并钳制到 [0,1],再映射到新区间,与 Pcg 原型刷新逻辑一致。刷新只改变匹配 +点的宽高、bounds 和 bendFactor;身份、位置、旋转、颜色及其他属性保持不变,失败不发布。 + +`grass::Point.widthScale` 为独立横向比例,0 表示沿用已有 height scale;直接上传时 +非零值须大于 0.001。内部顶点 normal.z 保留旧 0/1 的等比编码,新横向比例 w 在 +普通层编码为 -w、暗层编码为 1+w。Vulkan/WGSL 都先解码宽度和层标记,再各自应用 +横向、纵向比例;正的小比例不再被 shader 强制抬到 0.05。此为运行时网格编码, +未引入持久格式或新的权威数据副本。 + +### 多地形高度图事务 + +`mapTerrainOperationMultiTile(tiles,settings,domain,worldMap,names)` 提供不修改资源的通用 C++ +窗口计算。`TerrainOperationTile` 只描述名称、世界矩形、二维分辨率和地图域;返回值沿输入顺序给出 +每块 tile 的 local/operation 像素矩形。`Heightmap` 域按 Pcg 规则使用 `resolution-1` 的 tile +步长并补一个共享接缝像素;`Texture`、`TerrainDetail`、`Tree`、`GameObject` 与 `BakedMask` +使用完整 resolution,边界没有重复单元。不同域因此可共享筛选、旋转 AABB、白名单和溢出检查, +同时保留各自正确的接缝语义。 + +`applyTerrainStampMultiTile(tiles,stamp,settings,localMask,globalMask,worldMap,names)` 是 C++ +批量入口。每个 `TerrainHeightTile` 提供唯一名称、借用 Heightmap、世界原点/尺寸以及普通或 +world-map 域。所有 tile 必须使用相同高度图分辨率和世界像素间距,原点按完整 tile 跨度对齐; +不同分辨率不再像 Pcg 那样静默跳过,而是返回 InvalidArgument 并保持全部 tile。 + +操作像素范围遵循 Pcg MultiTerrainOperation 的高度图规则:世界范围以 floor/ceil 转为像素, +使用 `resolution-1` 作为相邻 tile 偏移,并包含共享接缝像素。仅与旋转矩形 AABB 边缘接触的 +邻 tile 不计入;实际修改仍由旋转 stamp 精确判断。普通与 world-map tile 分域,C++ 可传精确 +名称 allow-list。报告包含 operation 矩形、各 tile 的 local/operation 矩形、受影响 tile 数与 +改变样本数。 + +`eve.TerrainMultiTileWorkspace()` 是拥有式脚本会话。`addTile(name,heightmap,x,z,width,depth,worldMap)` +复制输入;成功 stamp 后 topology 固定。`stamp(stamp,settings,operation,localMask,globalMask,worldMap)` +先复制整个工作区,在候选 tile 上完成所有计算、报告和历史分配后整体交换。任何晚期数值或 +分配失败都不会留下部分 tile。`copyTile` 只导出副本;`undo`/`redo` 恢复所有 tile 与对应报告, +在 undo 后 stamp 会截断 redo 分支。`getTileCount`、`getLastAffectedTiles`、 +`getLastChangedSamples`、`getLastMappingCount`、`getOperationCount` 和 `getAppliedCount` 提供只读状态。 + +`applyTerrainDetailMultiTile(tiles,operationFitness,detailSettings,operationSettings,seed,worldMap,names)` +把通用窗口直接用于细节层。共享 fitness raster 的尺寸必须等于计算出的 operation 矩形;每块 +`TerrainDetailLayer` 先复制后计算,全部成功才共同发布。Replace 与 Pcg 一致:受影响 tile 的旧层 +先整体清零,只在映射矩形内生成;Add/Remove 保留矩形外数据。所有 tile 按描述符顺序共享同一个 +显式 XorshiftPlus 流,因此淡出带的条件随机抽样不会在 tile 边界重新播种。 + +脚本使用 `eve.TerrainMultiDetailWorkspace()`。`addTile` 复制输入层,`apply` 接受共享 fitness、 +detail settings、世界操作矩形、mode、seed 与地图域;`copyTile` 导出独立副本。`undo`/`redo` +恢复所有层及其报告,undo 后的新 apply 截断 redo 分支。只读计数接口与高度图工作区保持一致。 +两个会话都单线程拥有全部状态,不保留外部 raster、场景对象、回调或隐藏时间/RNG。 + +`applyTerrainTreesMultiTile` 将 Tree-domain operation raster 应用于多个 `PointSet`。Add/Replace +沿用 Pcg `SetTerrainTrees` 在该阶段的追加语义,Remove 仅删除 operation 矩形内匹配 asset 且 +fitness 严格超过阈值的点。所有 tile 共用 settings.seed 初始化的一个 XorshiftPlus 流;扫描范围 +在局部窗口末端增加一个生成步长,使 jitter 能从 tile 外回落到边缘。全部候选 PointSet、属性和 +总数完成后才共同发布。 + +脚本使用 `eve.TerrainMultiTreeWorkspace()` 深拷贝每块 tile 的初始 PointSet 与高度图;`apply` +的 mode 为 0=Add、1=Replace、2=Remove。`copyTile`、`undo`、`redo` 和统计接口与其他多地形 +工作区一致,历史快照同时覆盖所有 tree owner。 + +### 地形对象规则 + +`TerrainObjectPlacementSettings` 对应 Pcg `ResourceProtoGameObject` 与 SpawnRule 的外层扫描; +一个 prototype 可通过 `addInstance(TerrainObjectInstanceSettings)` 添加多个子资源。每个子资源 +独立配置数量区间、失败率、XYZ 偏移、地形/海平面/自定义 Y 基准、固定/随机/fitness/ +fitness-randomized 缩放、XYZ 旋转、沿坡面 Y 偏移、前向坡度对齐及完整坡度旋转。 + +外层脚本字段包括 `originX`、`originZ`、`width`、`depth`、`heightScale`、`spacing`、 +`spawnDensity`、`jitterPercent`、`startOffsetX`、`startOffsetZ`、`failureRate`、`minimumFitness`、 +`minimumInstanceFitness`、`minimumDirection`、`maximumDirection`、`boundsRadius`、 +`boundsCheckQuality`、`prototypeScale`、`boundsCollisionCheck`、`seaLevel`、`seed`、`namespaceId`、 +`maxPoints` 与 `prototype`。子资源字段包括 `asset`、`minimumInstances`、`maximumInstances`、 +`failureRate`、`minimumOffsetX`、`maximumOffsetX`、`minimumOffsetY`、`maximumOffsetY`、 +`minimumOffsetZ`、`maximumOffsetZ`、`customOffset`、`commonScale`、`minimumScale`、`maximumScale`、 +`minimumScaleX`、`maximumScaleX`、`minimumScaleY`、`maximumScaleY`、`minimumScaleZ`、 +`maximumScaleZ`、`scaleRandomPercentage`、`scaleRandomPercentageX`、`scaleRandomPercentageY`、 +`scaleRandomPercentageZ`、`minimumRotationX`、`maximumRotationX`、`minimumRotationY`、 +`maximumRotationY`、`minimumRotationZ`、`maximumRotationZ`、`yOffsetAlongSlope`、 +`alignForwardToSlope` 与 `rotateToSlope`。`setScaleMode` 和 `setYOffsetMode` 接受对应枚举序号。 + +`exportPoints(output,fitness,heights)` 使用一个显式 Pcg XorshiftPlus 流,依次执行规则失败、 +半幅 jitter、中心 fitness、自碰撞、bounds 范围平均 fitness、方向、子资源数量与失败、偏移后 +fitness、缩放、Y 偏移和旋转。结果进入原生 PointSet,携带 `asset`、`objectPrototype`、 +`objectCandidate`、`objectResource`、法线、变换和 bounds。稳定 ID 来自 namespace、候选位置、 +资源序号和实例序号;资产解析与 Scene/ECS 所有权由下游负责。`removePoints` 按 prototype、 +世界矩形和严格大于阈值的 fitness 原子删除。 + +跨地形版本使用 `eve.TerrainMultiObjectWorkspace()`。`addTile` 深拷贝 PointSet 与高度图; +`apply(fitness,settings,operation,mode,worldMap)` 的 mode 为 0=Add、1=Replace、2=Remove。 +所有映射 tile 按确定顺序共享一个 seed 随机流及一个自碰撞中心集合,因此接缝不会重新播种或 +允许重叠。Replace 先删除操作窗口内同 prototype 的旧点再生成;Remove 只删除 fitness 严格大于 +`minimumFitness` 的匹配点。`copyTile`、`undo`、`redo` 和统计方法与其他拥有式工作区一致。 +工作区不保留外部指针,且一次操作的所有 PointSet 和历史只在成功后整体发布。 + +坡度对齐在 EVEngine 中由采样地形法线推导 Euler 旋转;它保持 Pcg 的选项和数据依赖,但不声明 +与 Unity Quaternion 组合逐位相同。bounds 范围 fitness 使用 EVEngine 明确定义的含边界栅格采样。 + +### 地形纹理层权重 + +`eve.TerrainSplatmap()` 拥有任意层数的浮点权重。`initialize(width,height,layers,defaultLayer)` +建立唯一 topology 并把全部权重赋给默认层;`paint(paint,layer)` 以同尺寸归一化 Heightmap 覆盖 +目标层,其他层保持相对比例并共同归一化。原目标层为 1、其他层全零时,剩余权重确定性均分; +单层 splatmap 只能保持权重 1。所有 texel 先在候选缓冲计算,再一次发布。 + +`copyLayer(layer,output)` 将一个有效层完整复制到同尺寸 Heightmap,并返回变化样本数。这是 Pcg +ImageMask `TerrainTexture` 分支的原生数据桥:调用方按稳定资源引用解析出层,再把该标量图交给 +`generateImageMask` 的变换、曲线和混合阶段。层不存在或输出 topology 不匹配时不修改 output; +返回的是独立副本,后续绘制 splatmap 不会隐式改变已导出的遮罩。 + +`eve.TerrainTextureAlignSettings()` 以 `terrainAOriginX`、`terrainAOriginZ`、`terrainAWidth`、 +`terrainADepth`、`terrainBOriginX`、`terrainBOriginZ`、`terrainBWidth`、`terrainBDepth` 保存两块 +地形的世界原点和尺寸,并提供 `blendStrength`、`blendWidth`、`adjacencyTolerance`。默认相邻容差 +为 Pcg 的 1.5 世界单位。`eve.alignTerrainSplatTextures(a,b,settings)` 按 Pcg `TerrainTextureAligner` 的边定义 +识别 X/Z 相邻关系,在边缘向内逐行执行 SmoothStep 衰减及同步二维噪声调制的双向层混合,并逐像素 +重新归一化。两张 splatmap 使用私有候选副本,只有完整成功后才同时发布;非相邻、自别名、非法 +几何或参数不会留下单边修改。返回值是两张图合计发生变化的 texel 数。 + +C++ `paintTerrainSplatLayerMultiTile` 使用 Texture-domain 窗口将一张共享 operation raster 映射到 +多块 `TerrainSplatmap`,验证不同 tile 的所有 owner、topology、目标层及操作尺寸后统一提交。 +脚本 `eve.TerrainMultiSplatWorkspace()` 深拷贝每块 splatmap;`paint`、`copyTile`、`undo`、`redo` +和统计接口与高度、detail、tree 工作区采用相同事务语义,历史快照同时恢复全部纹理层。 +该公式依据 Pcg `SetSplatmap` 对 Unity `CopyTerrainLayer` pass 1 的数据流重建;Unity 内置 shader +源码不在 Pcg 包中,因此不声明逐位 GPU 等价。 + +### 相邻地形高度缝合 + +`eve.TerrainHeightStitchSettings()` 提供 `extraSeamSize` 和 `maxDifference`;后者保留 Pcg profile +字段,但 Pcg 4.2.2 当前活动算法同样没有读取它。`TerrainMultiTileWorkspace.stitch(terrainA,terrainB,settings)` +按两块地形原点差选择 North、South、West 或 East,验证共享边、分辨率、采样间距和栅格对齐,再复现 +`StitchBordersWithSeam`:从两侧内边界建立线性高度,向接缝逐点增加混合权重,最终用最靠近接缝的 +两个点平均并写入两侧共享边。操作成功后形成一个覆盖全部 owned tile 的 undo/redo 快照。 + +C++ `stitchTerrainHeightmaps(a,b,settings)` 可用于已有编辑事务;它同步借用两个不同 Heightmap,先在 +两个候选副本完成计算,再同时发布。无共同边、部分边未按样本对齐、非法 seam 或非有限数据不会修改输入。 + +`TerrainWorldWorkspace.setHeightWorldUnits(heightWorldUnits,worldHeightSpan)` 对应 Pcg Terrain Height +Adjuster。它将目标值转换为 `clamp(heightWorldUnits/worldHeightSpan,0,1)`,覆盖工作区内每块地形的 +全部归一化高度,并将整个世界作为一次可撤销事务发布。成功值是实际变化的样本总数;非有限高度、 +非有限或非正垂直尺寸在创建候选状态前失败。 + +`eve.TerrainSplatPalette()` 用 `addColor(r,g,b,a)` 按层添加归一化线性颜色,`getColorCount()` 返回 +当前颜色数;颜色数必须与 splat +层数相同。`eve.bakeTerrainSplatAlbedo(output,splatmap,palette)` 将全部层权重混合进同尺寸可写 +`ImageData`,成功时返回像素数。实现先写私有 ImageData 副本再 adopt,因此 palette、尺寸或格式 +验证失败不会改变旧图像。生成的 RGBA8 可直接传给 `gfx.newTexture`,示例右侧 terrain 已使用此路径。 + +`eve.GtsHeightBlendSet()` 按 splat 层顺序拥有原始高度 raster,并通过 +`addLayer(height,contrast,brightness,increase)` 保存 GTS alpha 高度变换。层数限制为 1–8,输入会复制。 +`eve.applyGtsHeightBlend(output,input,set,blendFactor)` 先计算每层“变换后高度 × 原权重”,再执行 GTS 的 +最大高度、transition、epsilon 与归一化公式,一次发布所有层权重。失败时 output 保持不变。 + +`eve.GtsPackedLayerSettings()` 配置 `triPlanar`、`stochastic`、`triPlanarSizeX`、`triPlanarSizeZ`、`tileSizeX`、`tileSizeZ`、`offsetX`、`offsetZ`、`tintR`、`tintG`、 +`tintB`、`normalStrength`、`aoMin`、`aoMax`、`smoothnessMin`、`smoothnessMax`、`geoAmount` 和 +`detailAmount`,以及 `heightContrast`、`heightBrightness`、`heightIncrease`、`displacementContrast`、 +`displacementBrightness`、`displacementIncrease` 和 `tessellationAmount`。`GtsPackedLayerSet.addLayer` 复制每层 +albedo+height 与 normal+AO+smoothness 纹理;`eve.bakeGtsPackedLayers` 按归一化 splat 权重重复双线性采样 +并一次生成 albedo、renderer-ready packed normal、geological strength 和 detail strength。平面路径使用地形 +局部 UV;stochastic 路径复现 GTS 的 `TriangleGrid` 与固定 sin/hash 偏移;triplanar 路径使用世界空间 +`ZY/XZ/XY` 投影、`(4,15,4)` 法线指数、轴符号翻转、`0.33/0.67` 偏移,并按 C# 上传规则将 size 乘 10。 +四个输出原子提交。 +`eve.bakeGtsPackedLayerDisplacement(displacement,tessellation,heights,splat,layers,cameraX,cameraY,cameraZ, +tessellationMultiplier,originX,originY,originZ,spacingX,spacingZ)` 对权重最高的四层复用同一平面 UV,读取 +albedo alpha 并按每层 displacement 参数变换,再分别加权位移和 tessellation amount。它复现 GTS shader +的 100000 平方距离硬截止,并在最后乘全局 tessellation multiplier;相机和地形变换均为显式输入。 +两个 Heightmap 原子提交,随后传给 weather PBR 时雪位移按原 shader 顺序覆盖混合。 + +`eve.generateGtsGlobalBlendDistance(output,heights,cameraX,cameraY,cameraZ,blendDistance,blendRange, +originX,originY,originZ,spacingX,spacingZ)` 精确生成 GTS 共用的近远混合 raster:世界空间相机距离平方乘 +`blendDistance/10000`,饱和后取 `blendRange` 次幂。相机位置和地形变换全部显式输入,便于回放时在同一 +相机状态重建;失败保留旧 output。 + +`eve.GtsColorMapSettings()` 配置 `alphaIntensity`、`colorIntensity`、`nearIntensity` 与 +`farIntensity`。`eve.bakeGtsColorMapAlbedo(output,colorMap,globalBlendDistance,settings)` 使用与输出同尺寸的 +颜色图及归一化近远混合 Heightmap,按 GTS 公式限制颜色和 alpha 后混入现有 albedo。它应在天气层之前调用。 +`eve.GtsMacroVariationSettings()` 配置三档 `sizeA/B/C`、最低亮度 `intensity` 和 `objectSpace`; +`eve.bakeGtsMacroVariationAlbedo(output,map,settings,originX,originZ,spacingX,spacingZ)` 按 +`sizeA/1000`、`sizeB/10000`、`sizeC/100000` 三次重复双线性采样红通道并相乘,随后在天气层之后调制 +albedo。两个步骤均同步、原子,不读取相机或隐藏时钟。 + +`eve.GtsGeologicalSettings()` 保存 GTS Geological 的 `enabled`、`objectSpace`、`nearStrength`、 +`nearNormalStrength`、`nearScale`、`nearOffset`、`farStrength`、`farNormalStrength`、`farScale` 和 +`farOffset`。`eve.bakeGtsGeologicalSurface(albedo,packedNormal, +heights,layerStrength,globalBlendDistance,geoAlbedo,geoNormal,settings,originY)` 沿地形高度轴重复采样 near/far +颜色条和 DXT5nm 法线条,用显式的逐像素地质层强度及近远距离混合它们。颜色图在 `[0,1]` 饱和,法线结果 +继续保持 renderer-ready packed RG,原有 AO 与 smoothness 通道不变。albedo 与 packedNormal 使用候选副本 +一起提交,任一 raster、纹理或参数非法时两者都不改变。 + +`eve.GtsDetailNormalSettings()` 提供 `enabled`、`objectSpace`、`nearTiling`、`nearStrength`、 +`farTiling` 与 `farStrength`。`eve.bakeGtsDetailSurface(albedo,packedNormal,detailGreyscale, +layerStrength,globalBlendDistance,detailNormal,settings,originX,originZ,spacingX,spacingZ)` 以世界或对象 XZ +坐标进行 near/far repeat-bilinear 采样,按距离混合 RG 法线,再以逐像素层权重叠加到 packed normal。 +它同时按 GTS 的法线 XY 平方长度给基础 albedo 添加微阴影,并输出未加权 `detailGreyscale`。 +随后用 `eve.bakeGtsWeatherAlbedoDetailed(...,detailGreyscale,originX,originZ,spacingX,spacingZ)` 可复现 GTS +对雪纹理额外施加的细节衰减。三个 detail 输出一起提交,失败不产生半更新。 + +`eve.GtsSnowSurfaceSettings()` 和 `eve.GtsRainSurfaceSettings()` 保存 GTS profile 的表面天气参数。 +雪参数为 `enabled`、`power`、`minimumHeight`、`blendRange`、`slopeBlend`、`age`、`scale`、 +`colorR`、`colorG`、`colorB`;雨参数为 `enabled`、`power`、`minimumHeight`、`maximumHeight`、 +`darkness`。 +`eve.bakeGtsWeatherAlbedo(output,heights,snowAlbedo,snowMask,snow,rain,originX,originZ,spacingX,spacingZ)` +按 GTS 顺序先混合雪色,再施加雨水变暗。雪使用世界高度平滑带、由高度梯度求得的坡度遮罩、积雪年龄、 +强度、重复纹理和 tint;雨使用最小/最大高度各 30 个世界单位的固定过渡,并将强度限制为 0.9。 +调用同步且原子,失败保留旧 output。 + +`eve.bakeGtsWeatherPbr(normal,mask,displacement,tessellation,heights,snowNormal,snowMask,rainData,snow,rain,timeSeconds,originX,originZ,spacingX,spacingZ)` +补充 GTS 的其余表面输出。`normal` 采用可直接交给 `Renderable3D.setNormalTexture` 的 packed 格式: +RG 是以 `[0,1]` 编码的切线法线 XY,B 是 AO,A 是 smoothness;绑定后调用 +`setPackedNormalMask(true)` 让 Vulkan/WGSL 重建法线 Z 并逐像素读取 AO/光滑度。`mask` 同时保留原始 +四通道 GTS 材质数据,两个 Heightmap 分别保存位移与细分强度。雪支持 `normalStrength`、`setMaskRemapMin`、 +`setMaskRemapMax`、`heightContrast`、`heightBrightness`、`heightIncrease`、`displacementContrast`、 +`displacementBrightness`、`displacementIncrease` 和 `tessellationAmount`。雨支持 `speed`、`smoothness` +和 `scale`。雨滴相位只读取显式 `timeSeconds`,因此回放不会依赖隐藏引擎时钟。四个输出先在私有副本 +完成,任一纹理、数值或结果非法时全部保持原状。 + +### 世界创建与统一历史 + +`eve.TerrainWorldCreationSettings()` 对应 Pcg `WorldCreationSettings` 的运行时拓扑字段:`tilesX`、 +`tilesZ`、`tileSize`、`tileHeight`、`centerX`、`centerZ`、`heightmapResolution`、 +`controlTextureResolution`、`detailResolution`、`treeResolution`、`objectResolution`、`splatLayers`、 +`defaultSplatLayer`、`defaultDetailDensity`、`worldMap`、`namePrefix` 与 `nameSuffix`。高度分辨率必须为 +`2^n+1`,control/detail 分辨率必须为 `2^n`。tile 原点按 Pcg 公式从完整世界中心向负半轴偏移; +普通名称为 `prefix_x_z-suffix`,world-map 使用 Pcg 的 `World Map_x_z-suffix` 前缀。 + +`eve.TerrainWorldWorkspace()` 同时拥有每块 tile 的 Heightmap、TerrainSplatmap、TerrainDetailLayer、 +tree PointSet 与 object PointSet。`create` 原子建立零高度世界;`stamp`、`paintSplat`、`applyDetail`、 +`applyTrees`、`applyObjects` 复用对应多 tile 内核。每次成功操作的历史快照覆盖五个领域,故 +`undo`/`redo` 不会产生从未共同存在过的高度与资源组合。`copyHeightmap`、`copySplatmap`、 +`copyDetail`、`copyTrees`、`copyObjects` 仅导出独立副本;tile 名称和原点查询返回结构化 Result。 +工作区单线程拥有状态,不保留外部 raster、回调、场景对象或隐藏时间源。 + +`flatten()` 对所有 tile 的高度执行 Pcg 式归零并形成一次可撤销操作。`TerrainWorldClearSettings()` +通过 `details`、`trees`、`objects`、`probes` 选择生成域,`clearSpawns(settings)` 在所有 tile 上一次性清除所选域; +至少要选择一项。`sourceNamespace=0` 清理任意来源;非零时只清理由对应 detail/tree/object/probe placement +`namespaceId` 生成的资源层或实例。`TerrainDetailLayer.sampleResource(namespaceId,x,z)` 查询单个资源层,`clearResource(namespaceId)` +只删除该层并返回发生变化的单元数;普通 `sample` 返回所有资源层之和。Splat 权重不属于 Pcg +`ClearSpawns`,因此不会被该操作修改。 + +查询 API 为 `getTileName(index)`、`getTileOriginX(index)`、`getTileOriginZ(index)`、`getTileCount()`、 +`getLastChangedSamples()`、`getLastAffectedTiles()`、`getOperationCount()` 与 `getAppliedCount()`。 + +### 有序 Spawner 计划 + +`eve.TerrainSpawnPlan()` 以添加顺序拥有 terrain modifier stamp、texture、detail、tree 和 object 规则。 +`addSplat`、`addDetail`、`addTrees`、`addObjects` 接收稳定且唯一的 `ruleId`,并深拷贝 paint/fitness raster、 +领域设置、操作窗口、目标纹理层、模式和显式种子; +之后修改调用方对象不会改变计划。`setEnabled(ruleId,enabled)` 按稳定身份切换规则,`getRuleCount()` +返回规则总数。 + +`addModifierStamp(ruleId,stamp,settings,operation,localMask,globalMask)` 加入 Pcg TerrainModifierStamp 资源, +其中 operation 使用与 `applyStamp` 相同的 0..5 枚举。 +印章及局部/全局蒙版均在加入时深拷贝,并与其他资源规则按同一顺序事务执行。 + +`TerrainWorldWorkspace.spawn(plan)` 按顺序执行全部启用规则,但只复制一次完整世界并只发布一次;任意 +后续规则失败会丢弃此前规则的候选结果,历史中只出现一个混合生成操作。空计划或全部禁用的计划会 +返回结构化失败,不制造无意义历史。执行仍使用各领域的跨 tile 映射、稳定资源 namespace 和显式 RNG。 + +`snapshotJson()` 输出确定性的 `eve.procgen.terrain-spawn-plan` schema version 3 JSON。根对象严格只允许 +`schema`、`version`、`payload`;payload 是完整规则、raster、设置、顺序与启用状态的固定小端二进制 +十六进制表示。`restoreJson(json)` 拒绝未知根字段、未知版本、非有限数值、非法枚举、重复规则 ID、 +尺寸/数量上限、截断或尾随 payload,并在完整候选通过后一次交换。Version 0 作为旧格式迁移入口, +其规则没有 enabled 字节,恢复时统一迁移为启用;version 0/1/2 均可恢复,再次保存会写为 version 3。 + +### Probe 资源点 + +`eve.generateTerrainProbes(output,fitness,heights,settings,type)` 导出 Pcg Probe 资源的拥有式 PointSet; +type 为 0=Reflection、1=Light。`eve.TerrainProbePlacementSettings()` 提供 `name`、`originX`、 +`originZ`、`width`、`depth`、`heightScale`、`spacing`、`jitterPercent`、`minimumFitness`、 +`seaLevelActive`、`seaLevel`、`reflectionOffset`、`lightOffset`、`reflectionResolution`、 +`reflectionClipDistance`、`reflectionShadowDistance`、`seed`、`namespaceId` 和 `maxPoints`。 + +扫描使用显式 Pcg XorshiftPlus 流。ReflectionProbe 在启用海平面时位于 +`max(terrainHeight,seaLevel)+reflectionOffset`;禁用时保留源码的 `500+seaLevel+0.2` 回退。 +LightProbe 仅在地形严格高于 seaLevel 时生成,并增加 lightOffset。输出携带稳定 ID、probe 类型、 +资源名、namespace、反射分辨率、裁剪距离和阴影距离;场景节点及实际反射捕获由 graphics/scene provider +消费这些值,不由 procgen 保存。 + +`TerrainSpawnPlan.addProbes(ruleId,fitness,settings,operation,mode,type)` 和 +`TerrainWorldWorkspace.applyProbes(...)` 将 Probe 纳入与其他生成域相同的跨 tile 事务;mode 为 +0=Add、1=Replace、2=Remove。Replace/Remove 只匹配操作窗口内相同 `probeResource` 的点,整次操作失败时 +所有 tile 保持原值。`copyProbes`、`clearSpawns`、undo/redo 和分步 SpawnPlan 都读写完整世界快照中的 +独立 Probe 状态。SpawnPlan version 3 持久化 Probe 类型、资源名、操作模式、fitness 与全部设置。 + +`eve.publishTerrainProbes(batchId,points)` 通过可选 `IProcgenProbeSink` 将完整 PointSet 原子发布给 graphics。 +Reflection 点创建并注册拥有式 `ReflectionProbeCapture`,Light 点作为同批稳定光照采样位置保留;再次发布 +同一 batch 会先构造完整候选再替换。`removeTerrainProbeBatch` 销毁批次及捕获资源, +`tickTerrainProbeBatches(faceBudget,filterBudget,filterSamples)` 按显式预算推进六面捕获和过滤。 +`getTerrainProbeBatchCount`、`getTerrainReflectionProbeCount`、`getTerrainLightProbeCount` 提供可观察状态; +graphics provider 缺失时发布、删除和 tick 返回 `Unsupported`,查询返回零。 + +### Biome Preset 生成栈 + +`eve.TerrainBiomePreset()` 对应 Pcg `BiomePreset.m_spawnerPresetList`。`addSpawner(entryId,plan, +activeInBiome,activeInStamper,autoAssignResources)` 按列表顺序深拷贝一个非空 Spawner 计划;entry ID +必须稳定且唯一。两个 active 标志分别控制 biome controller 自动生成和 stamper 联动生成, +`setActiveInBiome`、`setActiveInStamper` 按稳定 ID 修改。`getAutoAssignResources` 保留 Pcg +`m_autoAssignPrototypes` 的资源准备意图,实际资源仍由各规则的稳定 namespace/asset 引用拥有。 +`getSpawnerCount()` 返回 preset 当前拥有的 Spawner 条目数。 + +`TerrainWorldWorkspace.spawnBiome(preset)` 与 `spawnStamper(preset)` 只选择对应 scope 的条目,再按 +entry 顺序和内部 rule 顺序编译为一个计划。不同 Spawner 可复用局部 rule ID;编译时会用长度定界的 +entry ID 形成无歧义复合身份。整个栈仍只发布一个完整世界快照,因此跨 Spawner 的后置失败不会留下 +先前纹理或植被结果。没有对应 active 条目时返回结构化失败。 + +长生成栈可用 `beginSpawn(plan)`、`beginSpawnBiome(preset)` 或 `beginSpawnStamper(preset)` 启动,再用 +`stepSpawn(maxRules)` 按正数规则预算推进。状态 0=Idle、1=Pending、2=Completed、3=Cancelled、4=Failed; +`getSpawnStatus()` 和 `getSpawnCompletedRules()` 提供轮询。Pending 候选深拷贝完整世界及历史,规则执行 +产生的纹理、detail、tree、object 中间结果均不可通过 copy API 观察;最后一条规则成功后才形成一次 +历史提交。`cancelSpawn()` 或任意规则失败会丢弃完整候选。Pending 时 create、所有同步修改、undo/redo +均返回结构化失败,避免无声替换或分叉候选;读取最后发布快照仍然可用。 + +`snapshotJson()` 输出确定性的 `eve.procgen.terrain-biome-preset` schema version 1,包含有序条目、三个 +标志和每项完整的嵌套 SpawnPlan snapshot。`restoreJson(json)` 严格拒绝根或条目的未知字段、重复/空 ID、 +非法嵌套计划及尺寸上限,并在全部条目通过后一次交换。Version 0 没有 `autoAssignResources` 字段,迁移 +时采用 Pcg 原型自动准备的兼容默认值 true;再次保存写为 version 1。 +# Pcg PhotoMode 地形质量 + +`PcgTerrainPhotoModeAuthority` 通过显式 `setAuthority(true)` 成为照片模式 Terrain 域的唯一所有者。它保存并验证 draw-instanced、detail density、detail distance、heightmap pixel error 与 basemap distance。`selectLod` 将当前 pixel-error 直接用于既有 screen-space-error 地形 LOD 选择;`textureTier` 在相机距离越过阈值时返回命名的 Basemap 材质级别。细节实例渲染器读取 `state()` 中的 density、distance 和 instancing 标志,使同一份状态驱动网格、材质与植被细节。撤销或析构会释放 capability listener。 + +## GTS 地形网格切片 + +`GtsMeshSplitResult` 保存 `splitGtsMesh(output,source,xSplits,zSplits,pivot)` 产生的行优先网格切片。 +切割数量表示平面数,因此列数和行数分别为 `xSplits+1`、`zSplits+1`。每个源三角形会真正沿 X/Z +边界裁剪,交点同步插值位置、法线和 UV,跨越边界的三角形会进入相邻切片。 + +`pivot` 为 0 时保留源坐标,1 使用每块的最小 X/Z,2 使用中心 X/Z。通过 `getColumnCount()`、 +`getRowCount()`、`getTileCount()` 查询布局,`getTileOffsetX(index)`、`getTileOffsetZ(index)` 返回恢复 +世界位置的偏移,`copyTileMesh(index)` 返回调用者拥有的独立 `ProcgenMeshBuild`。无效索引返回 null, +无效拓扑、非有限顶点、越界索引及非法切分数通过 Result 报错,旧 output 保持不变。 + +`simplifyGtsMesh(output,source,quality,options)` 提供 GTS 网格导出的确定性 QEM 简化核心。`quality` 是 +`[0,1]` 三角形比例;折叠同步更新位置、法线和 UV,移除退化三角形后压紧顶点,并保留三角形组。 +`GtsMeshSimplificationOptions.preserveBorderEdges` 可锁定开放边界,`preserveUvSeamEdges` 可锁定同位置但 +UV 不同的 seam,`preserveUvFoldoverEdges` 可锁定 smart-link 中位置及 UV 均相同的 foldover 顶点组, +`preserveSurfaceCurvature` 将法线变化纳入折叠代价。迭代使用 `aggressiveness` 控制的 GTS 阈值逐步放宽; +每次坍缩会检查受影响面的新旧法向点积并拒绝翻面。边界保护允许内点坍缩到边界端点,因此不会移动 +边界轮廓。输出和输入可为同一 MeshBuild;所有验证与候选简化完成后才替换输出。 + +`buildDefaultGtsTerrainLods(output,source,xSplits,zSplits,pivot)` 把切片和逐级简化组合成 GTS 默认四层 +LOD:质量依次为 1、0.5、0.25、0.125,屏幕相对高度为 0.95、0.7、0.6、0.02。每一级以上一级 +结果为输入。`GtsTerrainLodSet` 拥有全部行优先 tile,通过布局查询、tile offset、 +`getLevelQuality(level)`、`getLevelTransitionHeight(level)` 以及 `copyLevelMesh(tile,level)` 供脚本消费; +无效 mesh 索引返回 null。C++ 的 +`buildGtsTerrainLods` 还接受逐层独立的简化选项,并在整个结果成功前不发布部分层级。 + +启用 `enableSmartLink` 时,简化器会按 `vertexLinkDistance` 连接重合的开放边界顶点。内部三角形分别保存 +几何拓扑索引与属性索引,因此绑定后的顶点共享 QEM 拓扑和坍缩位置,但仍输出各自的法线与 UV。 +重合顶点 UV 不同标记为 seam,UV 相同标记为 foldover;对应 preserve 选项会阻止该分类边被坍缩。 + +运行时可用 `selectLevel(relativeHeight)` 按 GTS/Unity 的屏幕相对高度选择层级;低于最后一个 transition +返回 -1 表示剔除,非法输入返回 -2。`getLevelSwitchDistance(level,worldDiameter,verticalFovDegrees)` 将 +同一阈值换算成 `Renderable3D.setMeshLod` 使用的相机距离,`selectLevelForCamera` 可直接按距离、包围体 +直径和垂直 FOV 选择。换算采用透视投影关系,因而不依赖固定分辨率。 + +`GtsTerrainLodSet.snapshotJson()` 输出 `eve.procgen.gts-terrain-lod` version 2,完整包含布局、逐层质量、 +transition、全部简化选项、tile offset、位置/法线/UV/index 流和每个三角形的 group 名称。 +`restoreJson(json)` 严格拒绝未知字段、未来版本、尺寸超限、非有限数值、无效索引或不匹配的 stream, +并只在所有 tile 和 level 解码成功后一次替换当前结果。 + +`Procgen.configureGtsTerrainTileLods(lods,tile,renderable,graphics,worldDiameter,verticalFov,originX,originY,originZ)` +先把指定非空 tile 的全部层级上传到同一 Graphics owner;全部成功后一次清空并配置 Renderable3D 的 +四层 LOD、三个切换距离、末级剔除距离和带 pivot offset 的世界位置。上传失败或参数无效时 renderable +保持不变。上传 mesh 由 Graphics 持有,Renderable3D 只保存借用引用。 + +`GtsTerrainLodRuntime.replace(lods,procgen,graphics,worldDiameter,verticalFov,originX,originY,originZ)` 为所有 +非空 tile 创建候选 Renderable3D;候选在完整配置前保持不可见,全部成功后才显示并销毁旧批。 +`getRenderable(tile)` 按原 row-major 槽位返回 ECS 借用实体,空 cell、无效或 stale 槽位返回 null; +`getTileCount()` 和 `getRevision()` 查询已提交状态。`clear()` 销毁全部实体并返回销毁数量。 +`GtsTerrainLodRuntime.applyMaterial(material)` 把同一个 Graphics 所有的材质借用引用应用到当前所有存活 +tile,并返回实际更新数量;运行时不取得材质所有权。`planGtsTerrainLodAssets(output,lods,terrainName, +meshFolder)` 生成确定性的导出清单。`GtsTerrainLodAssetPlan` 通过 `getEntryCount()`、`getTileIndex(index)`、 +`getLevelIndex(index)`、`getObjectName(index)`、`getMeshName(index)` 和 `getRelativePath(index)` 查询条目。 +对象名兼容 GTS 的 ` SubTile_LOD_`,mesh 名兼容其无分隔 level 后缀;EVEngine +清单路径使用 `/.eve.mesh.json` 作为稳定的作者侧标识;它不是可直接加载的独立文件。 +`asset_procgen::prepareGtsTerrainLodPackage` 会把全部非空层实际编码为 `eve.mesh/3` definition 与 EVMESH/3 +bulk,并生成可交给 `buildEvaArchive`、AssetCooker 和 EvpackGraphicsLoader 的完整 `.eva` 候选包。非法名称、 +路径、数组、索引或预算不会发布部分候选。 + +`GtsTerrainLodRuntime.replaceAndHideSource(lods,procgen,graphics,sourceTerrain,worldDiameter,verticalFov, +originX,originY,originZ)` 在完整 LOD 批成功发布后隐藏源 Renderable3D,并记住它进入交接时的可见性。 +替换失败不会修改源或旧批;换用另一个源时先恢复前一源,`clear()` 和运行时析构也恢复当前源。 +源引用保存为 ECS generation handle,因此源先销毁时 `getSourceTerrain()` 返回 null,后续清理不会访问 +陈旧实体。受运行时管理的 LOD tile 不能同时作为源。 + +`buildGtsTerrainBaseMesh(output,heightmap,saveResolution,sizeX,sizeY,sizeZ)` 完成 GTS 转换的高度场前置阶段。 +`saveResolution` 为 0=Full、1=Half、2=Quarter、3=Eighth、4=Sixteenth,对应 1/2/4/8/16 的源样本步长。 +输出覆盖完整 terrain 尺寸与 `[0,1]` UV,Y 为高度样本乘 `sizeY`;法线从实际导出三角形重新计算。 +高度场间隔不能被步长整除、样本非有限、尺寸非法或网格超预算时返回结构化错误,旧 output 保持不变。 + +`buildDefaultGtsTerrainLodsFromHeightmap(output,heightmap,saveResolution,sizeX,sizeY,sizeZ,subTiles,pivot)` +原子执行 Pcg 的完整默认转换链。`subTiles` 与 GTSProfile 一致,表示 X/Z 两轴各自的切割平面数,因此 +默认值 3 产生 4×4 个 row-major tile;允许范围为原编辑器的 0–5。任一基础网格、切片或顺序简化阶段 +失败都不替换旧 LOD set。 + +`GtsTerrainMeshSettings` 对应原包的 `GTSMeshSettings`,默认保存 Full、4 个 LOD、 +`100/50/25/12.5` 百分比和 3 个 split。LOD count 限制为 1–4,质量允许编辑器同样的 0–100;最后一个 +激活 LOD 总使用 0.02 剔除阈值,因此 1/2/3/4 层配置分别得到正确的 GTS transition 序列。 +`snapshotJson/restoreJson` 使用严格的 `eve.procgen.gts-terrain-mesh-settings` version 1 schema,未知字段、 +非法范围或错误数组长度不会修改旧设置。`buildGtsTerrainLodsFromHeightmap(output,heightmap,settings,...)` +直接消费这份配置。 +脚本用 `getSaveResolution/setSaveResolution`、`getLodCount/setLodCount`、`getSubTiles/setSubTiles`、 +`getLodQuality/setLodQuality` 和 `getLodTransitionHeight` 查询或修改各字段。 + +### GTS 地形导出配置 + +`GtsTerrainExportSettings` 对应原包的 `GTSExportTerrainSettings` 中源地形与 impostor 两组 LOD +配置。`appendSourcePreset(mode,level,quality,transition)` 与 +`appendImpostorPreset(mode,level,quality,transition)` 中 mode 取 0=Impostor、1=LowPoly;它们精确应用 +原版 `SetLODToImpostorMode` / `SetLODToLowPolyMode` 的采样分辨率、纹理分辨率、锐边、烘焙方式、 +法线图、顶点色与材质策略。transition 必须按列表严格递减,quality 使用 `[0,1]`。 +`getSourceLodCount()` 和 `getImpostorLodCount()` 返回两组数量。 + +`snapshotJson()` / `restoreJson(json)` 使用严格的 +`eve.procgen.gts-terrain-export-settings` version 3 schema,保存顶层工作流、每层所有可执行字段和 QEM 参数;未知字段、 +非法枚举、纹理分辨率、非递减阈值或尺寸超限会失败;version 1 自动迁移并采用原 Pcg 顶层默认值;version 2 的 OBJ 面格式迁移为 Triangles,恢复只在整个候选有效后提交。C++ 的 +`compileSourceLevels()` 将源导出记录直接编译成现有顺序 LOD 网格管线使用的设置。 + +`buildGtsTerrainExportLodsFromHeightmap(output,heightmap,settings,sizeX,sizeY,sizeZ,subTiles,pivot)` +使用 source LOD 第一项自己的 SaveResolution 构造基础网格,再按完整 source 列表逐级简化;设置中的 +分辨率不再只是持久化元数据。`buildGtsTerrainColliderMeshFromHeightmap(output,heightmap,settings,...)` +使用 workflow 的独立 colliderResolution 和 colliderSimplifyQuality 生成 MeshCollider 三角网格;只有 +addTerrainCollider=true 且 colliderType=Mesh 时接受调用。两条入口都在最终候选完成后替换 output。 + +`encodeGtsTerrainObj(heightmap,saveResolution,sizeX,sizeY,sizeZ,faceMode)` 返回确定性的 UTF-8 Wavefront +OBJ 文本。faceMode 为 0=Triangles、1=Quads;坐标按原 Pcg 导出器写成 +`(-terrainZ,height,terrainX)`,`vt` 同样保持原版的 `(v,u)` 顺序和一基 face 索引。它使用固定经典 locale +和足以往返 float 的精度,不依赖系统小数点设置。导出设置 version 3 保存 workflow 的 objFaceMode; +version 2 迁移时采用原默认 Triangles。 + +`encodeGtsMaskedTerrainObj(heightmap,maskmap,saveResolution,sizeX,sizeY,sizeZ,faceMode,threshold,invert)` +复现 Pcg `MaskedTerrainMesh` 的 masked OBJ 路径。它以每个输出格左下角的归一化 mask 样本分类整格: +低于 threshold 属于 outside,其余属于 inside;invert=false 输出 inside,true 输出 outside。原版默认调用 +threshold=0.2。maskmap 可以使用与 terrain 不同的尺寸,采样采用原版 `normalized * dimension` 双线性规则; +输出继续使用原版的 X/Z 顶点去重、UV 缩放和逆时针面序。 + +`bakeGtsTerrainVertexColors(output,source,bakedTexture,edgeMode,smoothingIterations,terrainSizeX,terrainSizeZ,linearize)` +执行 Pcg `ProcessEdgesAndColorBaking`。Smooth 保留共享顶点并按世界 X/Z 采样;Sharp 为每个三角形角展开顶点、 +重算法线,并把三角形三个纹理样本的平均色写给整面。平滑迭代使用原版四邻域、边缘 clamp 算法;linearize +显式对应 SRP OrthographicBake 的 `.linear` 分支。颜色是 `ProcgenMeshBuild` 的可选 RGBA 顶点流,可通过 +`hasVertexColors()` 和 `getColor(vertex,component)` 查询。裁剪、QEM、group copy、变换及 LOD version 2 +快照都会保留颜色;旧 version 1 LOD 快照仍可迁移读取。 + +### Pcg Mask Map Export + +`combinePcgMaskMapChannels(output, red, green, blue, alpha, activeChannels)` 与 Pcg 的 +`PW_ChannelCombine.shader` 一致:四张输入图各自仅取 red 分量,按 RGBA bit mask 写入目标;未启用通道写 0, +输入尺寸或 bit mask 非法时目标保持不变。`placePcgMaskMapTileInto(atlas, tile, x, y, width, height)` 使用 +像素中心双线性缩放并事务写入 atlas 矩形,对应 `OneCombinedTexture` 的逐 terrain 拼接。文件编码继续使用 +ImageData 既有 PNG/JPG/EXR 输出接口。 + +### Pcg 通用 Mesh LOD Generator + +`PcgMeshLodProfile.appendLevel(transitionHeight, fadeWidth, quality, combineMeshes, combineSubMeshes)` +按近到远加入最多四级配置;transition 必须严格递减,三个标量均使用 0..1 范围。 +`buildPcgMeshLods(output, sourceMesh, profile)` 为每一级从同一份原始 MeshBuild 独立执行简化,完整成功后 +才替换 `PcgMeshLodSet`。可用 `copyLevelMesh(index)` 取得拥有式副本,或用 `selectLevel`、 +`getSwitchDistance` 检查投影阈值。 + +`procgen.configurePcgMeshLods(lods, renderable, graphics, worldDiameter, verticalFovDegrees)` 会先上传全部层, +然后一次替换 Renderable3D 的 LOD 链并设置最远层裁剪距离。上传或参数校验失败时 renderable 保持原状。 +`profile.setFadePolicy(mode, animate, duration)` 保存 Unity `LODFadeMode`:0=None、1=SpeedTree、 +2=CrossFade。各级 `fadeWidth` 与全局策略会随 output 原子写入 Renderable3D。距离模式在切换点前按当前 +级距离区间乘 fadeWidth 生成互补权重;末级可渐隐到 cull。动画模式由 +`Renderable3D.advanceMeshLodTransition(distance, dt)` 使用调用方注入的 dt 确定推进。 + +`profile.setLevelRendererState(level, skinQuality, shadowMode, receiveShadows, motionMode, +skinnedMotionVectors, lightProbeUsage, reflectionProbeUsage)` 保存 Pcg `LODLevel` 的逐级 renderer 策略。 +枚举数值与 Unity 一致,包括 `Bone4=4` 与 `CustomProvided=4`。`configurePcgMeshLods` 在所有 mesh 上传后 +把整条网格链和状态一次写入 Renderable3D;选中级别的 Off/On/ShadowsOnly、receive-shadow、Object/ +ForceNoMotion 和 reflection-probe Off 已进入主渲染路径。 + +`PcgMeshLodBackup.capture(renderable)` 在替换前保存完整 MeshRenderer 状态以及实体 id/generation; +`restore(renderable)` 仅对同一实体 generation 原子恢复并消费快照,错误目标不会改变自身或消费备份。 +原 mesh、parts、material 等资源仍由同一个 Graphics 实例拥有,Graphics 与这些资源必须比 backup 活得更久。 +`discard()` 可只丢弃备份。该对象对应 Pcg `LODBackupComponent` 与 `DestroyLODObject` 的恢复职责。 +脚本查询与矩阵接口为 `getAnimateCrossFading()`、`getCrossFadeAnimationDuration()`、`getFadeMode()`、 +`getFadeWidth(level)`、`getLevelRendererState(level,field)`、`getQuality(level)`、 +`getTransitionHeight(level)`、`getSourceCount()`、`getElement(row,column)`、`setElement(row,column,value)`; +备份可用 `getEntityId()`、`getEntityGeneration()` 与 `isCaptured()` 检查身份和状态。 + +`PcgMeshTransform` 保存 root-relative 4×4 row-major 矩阵;用 +`PcgMeshCombinePlan.appendSource(mesh, transform, defaultMaterialId)` 按 renderer 顺序复制输入。 +`combinePcgStaticMeshes(output, plan)` 依次变换顶点并按首次遇到的 material id 合并 triangle group;任一输入 +带颜色时,无颜色输入会补白色 RGBA。`buildPcgCombinedMeshLods(output, plan, profile)` 先组合一次,再让所有 +启用了 `combineMeshes` 的 level 从同一份组合结果独立简化。混合开启/关闭 combine 的 profile 会明确失败, +避免生成与 Pcg renderer 拆分结构不一致的结果。两个入口都只在完整成功后替换 output。 +### Pcg Terrain Watcher + +`PcgTerrainWatcher` 将 Pcg 编辑器中基于 hierarchyChanged 的 Terrain 创建/删除检测改为 +显式、确定性的快照事务。调用 `beginScan()` 后按场景稳定顺序调用 `addTerrain(id)`,最后以 +`commitScan()` 原子发布;首次提交只建立缓存,不产生 Created 事件。 +`PcgTerrainWatcher.getTerrainCount()` 返回当前缓存规模;后续结果通过 `getChangeCount()`、 +`getChangeTerrainId(index)` 和 `getChangeType(index)` 读取,其中类型 0 为 +Created、1 为 Removed。新增项按本次扫描顺序排列,随后删除项按上次扫描顺序排列。 +`cancelScan()` 丢弃候选;重复/空 id 或错误调用顺序返回 Result 且不改变已发布集合。 + +### Pcg Tree Manager + +`PcgTreeManager` 汇总多个地形生成出的世界空间树位置。先以 `reset(minX, minZ, width, depth)` +建立 Pcg Quadtree 对应的半开世界边界,再用 `addTree(x, z, prototype)` 或 +`addTrees(pointSet, prototype)` 加入树;批量输入会完整校验后一次发布。 +`countInRange(x, z, range)` 与 Pcg 源码一致使用包含边界的轴对齐正方形查询,而不是圆形距离; +`PcgTreeManager.getCount()` 返回当前总树数。管理器复制坐标与 prototype index,不保留 PointSet。 + +### Pcg Biome Controller 自动加载边界 + +`eve.PcgBiomeController()` 保存一个 biome 的世界位置、生成半径与 TerrainLoader 模式。`configure` +使用 Pcg 原始公式计算 regular 立方边界:边长为 `range * 2 - 0.5`,用于避免范围恰好落在 tile +边缘时加载多余相邻地形;正数 impostor range 直接加到该边长,零值关闭 impostor 边界。模式编号与 +原包一致:0 Disabled、1 EditorSelected、2 EditorAlways、3 RuntimeAlways。 + +`fitToTerrain(minX, originY, minZ, sizeX, sizeY, sizeZ)` 使用单块地形 X/Z 中心、地形原点 Y 和 X +半宽;`fitToAllTerrains` 使用聚合边界完整中心及 X 半宽。`tierAt` 返回 0 Regular、1 Impostor 或 +2 Unloaded,可直接驱动调用方的地形驻留调度。`getRange`、`getLoadMode`、`getRegularCenterX`、 +`getRegularCenterY`、`getRegularCenterZ`、`getRegularSize` 与 `getImpostorSize` 提供脚本侧状态读取。 +所有更新先验证完整候选,失败时保持旧边界。 +### Pcg Runtime Stamper + +`eve.PcgRuntimeStamper()` 移植 Pcg Pro `RuntimeStamper.cs` 的完整运行时编排。调用 +`configure(address, showGui, showDebug)` 后,由宿主资源系统加载高度图并传给 +`loadStamp(stamp, resourceName)`,再用 `execute(target, originX, originZ, spacingX, spacingZ)` +执行清平、适配完整地形范围、应用高度 6、零旋转和中心到边缘的线性距离遮罩。执行采用候选副本, +任一步骤失败都不会留下部分修改。资源路径会把反斜杠统一为 `/`;缺失资源可通过 +`reportMissingStamp()` 进入可观察的失败状态。`getUpdateTimeAllowed()` 固定返回 Pcg 原示例的 +`1/15` 秒预算,`updateLayout()` 保留 300x20 底部居中进度标签的位置语义。脚本可通过 +`getStatus`、`getStampAddress`、`getProgressText`、`getLabelCenterX` 与 `getLabelCenterY` 读取状态。 + +#### 第二百一十九批:RuntimeStamper + +- 对照 `RuntimeStamper.cs` 保留资源地址默认值、路径归一化、加载/失败/完成状态与进度文本。 +- 将 `FlattenTerrain -> FitToTerrain -> height=6 -> linear distance mask -> rotation=0 -> Stamp` + 组合为一次原子操作,同时复用 EVEngine 的 `Heightmap` 与 `applyTerrainStamp`。 +- 控制器拥有 stamp 副本,不跨帧保留调用方指针;无回调、无锁、无隐式时间或随机数。 + +### Pcg Spawn Progress + +`eve.PcgSpawnProgress()` 保存跨 spawner 的规则进度。`updateRule` 设置名称、总规则计数和当前 +spawner 计数,`updateRuleFraction` 更新当前规则的小数进度;`getProgress` 使用 +`(totalCompleted + fraction) / totalCount`。`getTitle`、`getSubtitle`、`getStatus` 提供 UI 所需快照, +`requestCancel` 发布取消请求,`clear` 隐藏并清空状态。所有更新先验证候选,非法计数或进度不修改旧值。 +## GridGraph、PointGraph 与 MeshGraph 混合编排 + +`GridGraph` 是面向语义格子的确定性数据流图。内建掩码生成节点覆盖填充、随机噪声、棋盘、点阵、 +形状、元胞自动机、随机游走、迷宫和 Poisson 撒点;`generate.registry` 则直接复用现有 +`GeneratorRegistry`,以 `algorithm` 字符串选择 `dungeon.bsp`、`level.roguelike`、WFC 等成熟 +生成器,并把节点上的整数、浮点和字符串参数投影为 `Params`。地牢算法只保留注册表中的一份实现。 +修改与选择节点覆盖布尔组合、反转、膨胀、收缩、平滑、随机/边缘/邻居/规则/岛屿/岛心/ +细节范围/语义选择、寻路与八邻域 autotile。固定 seed 的生成结果可复现。 + +`convert.grid_to_points` 是显式的类型边界:它将匹配语义的格子中心转换为 `PointSet`,并写入 +`cell_x`、`cell_y`、`semantic` 和 `detail` 属性。`point.subgraph` 随后可把这组点送入独立的 +`PointGraph`,用于筛选、变换、采样和组合。 + +`MeshGraph` 接受 `Grid2D`、`PointSet` 与 `MeshBuild` 三种强类型输入。`mesh.grid_tiles` 从格子 +构造顶面和暴露侧墙,并按 +`group/semantic/tile-kind/rN/(normal|mx)/reference-configuration` 建立命名三角形组。 +其中标准网格 mask 会映射到 TileWorldCreator 4.3.5 的 3×3 配置表,直接携带瓦片类别、 +0/90/180/270 度朝向和 X 镜像语义;原版未归类的配置 350 保持为 `none`。 + +`eve.ProcgenObjectBuildLayer()` 是 Objects Build Layer 的原生对应物。它接收 PointGraph 产生的 +`PointSet`,按稳定点身份和层 seed 确定性选择加权 `asset`,并应用层偏移、位置散布、Euler +旋转、统一或非统一缩放。`setOrientation` 可按北、东、南、西优先级朝向另一点层; +`setPlaceOnTop` 读取输入点的 `surface_highest_y` 或 `surface_lowest_y`。`addChild` 生成带 +`object_role=child` 与 `parent_index` 的子对象点。结果仍是普通 PointSet,可继续送入 PointGraph、 +`mesh.instance_points` 或实例发布接口。 + +对象层 API 速查:`clearAssets` 清空加权资产;`setLayerOffset`、`setLayerScale` 设置层级变换; +`setPositionRadius` 设置平面散布半径;`setRandomRotation`、`setRandomScale` 设置确定性随机范围; +`disableOrientation` 关闭朝向层;`clearChildRules` 清空子对象规则;`buildOriented` 使用显式朝向点集 +执行对象层。GridGraph 与 MeshGraph 的运行时输入分别通过 `setNodeGrid` 绑定;GridGraph 使用 +`setNodePointSubgraph` 绑定拥有明确输入/输出节点的 PointGraph 子图。 + +`eve.ProcgenBuildLayerStack()` 将 Tiles 与 Objects 定义放进同一个有序执行器。`addTileLayer` 和 +`addObjectLayer` 保存值副本,`setEnabled` 控制参与执行的层;`execute` 只在全部启用层成功后返回 +`ProcgenBuildLayerExecution`,再通过 `getType/getMesh/getPoints` 获取具名 artifact。配置使用 +`EVPCG_BUILD_LAYERS 1`,对象层使用嵌入式 `EVPCG_OBJECT_LAYER 1`;反序列化拒绝未知记录和尾随字段, +并在完整校验成功后原子替换旧配置。 + +层栈可用 `getLayerId`、`getLayerType` 和 `isLayerEnabled` 查询定义;`executeOriented` 为需要朝向 +点集的对象层执行完整栈。增量执行器的 `getCachedClusterCount` 返回当前成功提交的活动簇数量, +失败构建不会改变该计数。 + +`eve.ProcgenIncrementalBuildExecutor()` 为 BuildLayerStack 增加跨次构建缓存。`update` 接收簇边长(格子数) +和格子世界尺寸,返回 `ProcgenIncrementalBuildDelta`;每项通过 `getClusterX/getClusterZ/isRemoved` 标识 +场景侧应更新或删除的簇,非删除项用 `getArtifacts` 取得该簇的完整层结果。格子哈希带一格 halo, +因此边界 autotile 改动会同时使相邻簇失效;点按半开世界坐标范围归属一个簇。任一脏簇构建失败时, +内部旧缓存不变。带朝向层时使用 `updateOriented`;`getCachedArtifacts` 可按簇坐标读取当前快照。 +场景侧应以 `clusterX:clusterZ` 作为派生缓存键:普通 delta 替换该簇的渲染/碰撞 artifact,removed +delta 先从场景隐藏或移除旧对象再删除缓存项。空 PointSet 是合法簇结果,不应尝试上传空实例网格。 +示例中的物理消费者按同一键维护静态三角网格刚体:先成功创建替换体再销毁旧体,避免失败时留下 +无碰撞窗口;裁剪掉 `physics` 模块时明确退化为纯渲染路径,不影响生成结果与缓存权威状态。 +`mesh.instance_points` 把任意 `MeshBuild` 实例化到点集;`mesh.merge` 与 `mesh.transform` 完成 +组合和变换。输出仍是现有 `MeshBuild`,可继续交给统一发布、碰撞、材质和场景生命周期。 + +三个图各自拥有缓存,不共享跨帧可变裸指针。连接时检查端口类型,Grid 输出不能直接连到 +Point 或 Mesh 输入,必须经过显式转换或输入绑定。图拓扑、参数或输入变化会从修改点向下游 +失效缓存,相同输入重复执行会复用结果。 + +脚本通过 `procgen.newGridGraph()` 与 `procgen.newMeshGraph()` 获取模块拥有的句柄;所有失败均为 +统一 Result 投影。两种图定义都支持版本化 `serializeDefinition()` / `deserializeDefinition()`; +运行时输入值刻意不写入定义,加载后由调用方重新绑定,避免资产与场景实例形成第二份权威状态。 + +TileWorldCreator 4 运行时核心能力的逐项对应和边界见 +[`TileWorldCreator4核心移植.md`](../../dev/TileWorldCreator4核心移植.md)。 diff --git a/docs/usr/modules/procgen_animation.md b/docs/usr/modules/procgen_animation.md new file mode 100644 index 00000000..c1d8a904 --- /dev/null +++ b/docs/usr/modules/procgen_animation.md @@ -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。 diff --git a/docs/usr/modules/procgen_physics.md b/docs/usr/modules/procgen_physics.md new file mode 100644 index 00000000..58c9620e --- /dev/null +++ b/docs/usr/modules/procgen_physics.md @@ -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。 \ No newline at end of file diff --git a/docs/usr/modules/rpg.md b/docs/usr/modules/rpg.md index 3d9ca4e3..3fdc32a9 100644 --- a/docs/usr/modules/rpg.md +++ b/docs/usr/modules/rpg.md @@ -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 `、`wait <秒>`、`end`。 + +条件是 `变量 运算符 字面量`,可用 `&&`、`||`、`!` 和括号组合。变量名用点号寻址 +`GameState`:`switch.<名>`、`variable.<名>`、`self..<名>`。 + +### 角色移动(跨游戏模式) + +`.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..completed` 写进 `GameState`, + 再次 `begin` 会失败;`repeatable` story 每次重新开始。 +- **快照**:`begin` 时快照整份目录,热替换不会让活动会话悬空。 + ## 常见问题 - 只注册 Skill 未注册其引用的 Effect。 diff --git a/docs/usr/modules/scene.md b/docs/usr/modules/scene.md index d92a6ee7..beccdad4 100644 --- a/docs/usr/modules/scene.md +++ b/docs/usr/modules/scene.md @@ -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()`。 diff --git a/docs/usr/modules/system.md b/docs/usr/modules/system.md index 36207322..24d794fb 100644 --- a/docs/usr/modules/system.md +++ b/docs/usr/modules/system.md @@ -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 使调度可回放,非法时间不会改变队列。 diff --git a/docs/usr/modules/ui.md b/docs/usr/modules/ui.md index 3ebd4911..9fdc55d3 100644 --- a/docs/usr/modules/ui.md +++ b/docs/usr/modules/ui.md @@ -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()` 也使用这两个条件与场景名、渲染管线共同决定是否允许加载旧设置。 diff --git a/docs/usr/modules/weather.md b/docs/usr/modules/weather.md index 180af2ca..6f573964 100644 --- a/docs/usr/modules/weather.md +++ b/docs/usr/modules/weather.md @@ -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 启用时保存原状态,撤销或析构时恢复。 diff --git a/examples/README.md b/examples/README.md index fd4230b5..2fb39afc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,6 +9,36 @@ make run/-debug GAME=examples/ 部分示例另有 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//` 要么是可运行示例(`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 +``` + +Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/stderr,`--print-missing-rows` +打印的中文标题不会因为控制台代码页是 GBK 而报错(约定见 `AGENTS.md` 与 +`scripts/tests/test_utf8_stdio.py`)。 + ## 开发者体验 | 示例 | 演示能力 | @@ -16,8 +46,8 @@ make run/-debug GAME=examples/ | [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/-debug GAME=examples/ | [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/-debug GAME=examples/ |---|---| | [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/-debug GAME=examples/ |---|---| | [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 解算) | ## 与设计目标对照 diff --git a/examples/autotile-production/README.md b/examples/autotile-production/README.md new file mode 100644 index 00000000..a14edcfa --- /dev/null +++ b/examples/autotile-production/README.md @@ -0,0 +1,56 @@ +# Autotile Production — 岸线、墙体与瀑布的自动拼接生产示例 + +本示例在不使用任何美术资源的前提下,用 `map.newLayer` 建立三层瓦片层,分别演示岸线、 +墙体与瀑布三类自动拼接(autotile family)语义。它同时覆盖邻接掩码规则登记、矩形批量 +绘制与擦除、以及循环瓦片动画,可作为自动拼接落地到关卡数据前的最小生产参考。 + +## 运行 + +```bash +make run/-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`:本文件。 diff --git a/examples/basic/root.nut b/examples/basic/root.nut index 892cd95f..abf02919 100644 --- a/examples/basic/root.nut +++ b/examples/basic/root.nut @@ -1,4 +1,4 @@ -print("EVEngine v0.5.1") +print("EVEngine v0.5.2") print("-----------------------------------------") diff --git a/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action b/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action new file mode 100644 index 00000000..4041942a --- /dev/null +++ b/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action @@ -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} \ No newline at end of file diff --git a/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action.evmeta b/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action.evmeta new file mode 100644 index 00000000..1a137a6d --- /dev/null +++ b/examples/combat-action-editor/Content/Actions/KayKitFollowUp.action.evmeta @@ -0,0 +1 @@ +{"guid":"asset.kaykit.follow-up","schemaVersion":1,"type":"eve.action.timeline"} \ No newline at end of file diff --git a/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action b/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action new file mode 100644 index 00000000..4450da0f --- /dev/null +++ b/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action @@ -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} \ No newline at end of file diff --git a/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action.evmeta b/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action.evmeta new file mode 100644 index 00000000..ab6cc544 --- /dev/null +++ b/examples/combat-action-editor/Content/Actions/KayKitLightAttack.action.evmeta @@ -0,0 +1 @@ +{"guid":"asset.kaykit.light-attack","schemaVersion":1,"type":"eve.action.timeline"} \ No newline at end of file diff --git a/examples/combat-action-editor/README.md b/examples/combat-action-editor/README.md index 4795ec83..467d0e47 100644 --- a/examples/combat-action-editor/README.md +++ b/examples/combat-action-editor/README.md @@ -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 diff --git a/examples/combat-action-editor/main.nut b/examples/combat-action-editor/main.nut index 221efbb0..c24dda54 100644 --- a/examples/combat-action-editor/main.nut +++ b/examples/combat-action-editor/main.nut @@ -4,9 +4,23 @@ const HITBOX_ID = "kaykit-state:hitbox"; persist combatEditor = { - workspace = null, timeline = null, camera = null, skeleton = null, + workspace = null, timeline = null, camera = null, cameraController = null, skeleton = null, + actionModule = null, assetCatalog = null, tabs = [], activeTab = 0, closeArmed = -1, tabUiSignature = "", + assetPickerOpen = false, assetSearch = "", + inspectorMode = "joint", selectedActionId = "", selectedActionState = false, + insertPanelOpen = false, newSectionOpen = false, nextSectionId = 0, + insertTrack = 0, insertNotifyType = 0, insertStateType = 2, + selectedTrack = 0, selectedSplit = 0, newTrackOpen = false, newTrackKind = 0, nextTrackId = 0, clipEditor = null, boneMouseDown = false, - clip = null, player = null, knight = null, knightParts = [], skins = [], + clip = null, player = null, basePlayer = null, knight = null, knightParts = [], skins = [], + enemySkeleton = null, enemyPlayer = null, enemyParts = [], enemySkins = [], + combatRuntime = null, combatTick = 0, + abilityGrantId = 0, abilityActivationId = 0, abilityPhase = "ready", abilityCooldown = 0.0, + comboWindowOpen = false, comboInputTag = "", attackWas = false, comboCount = 0, + playbackRate = 1.0, slowWas = false, normalWas = false, fastWas = false, + playerSubject = "01020304-0506-4708-890a-0b0c0d0e0f10", + enemySubject = "11121314-1516-4718-991a-1b1c1d1e1f20", + playerHealth = 100.0, enemyHealth = 80.0, enemyReaction = "none", generalLibrary = null, meleeLibrary = null, swordParts = [], knightTexture = null, ground = null, mouseDown = false, keyLight = null, fillLight = null, @@ -14,13 +28,93 @@ persist combatEditor = { selectedAsset = "Melee 1H Attack Chop", selectedItem = "Nothing selected", previewOrbiting = false, previewLastX = 0.0, previewLastY = 0.0, previewYaw = 0.65, previewPitch = 0.28, previewDistance = 5.3, - spaceWas = false, undoWas = false, redoWas = false, + spaceWas = false, undoWas = false, redoWas = false, saveWas = false, frame = 0, screenshotSaved = false, }; +// Persisted editor state survives script revisions. Add newly introduced UI +// fields explicitly so old workspaces migrate without discarding open tabs. +local editorUiDefaults = { + insertPanelOpen=false, newSectionOpen=false, nextSectionId=0, + insertTrack=0, insertNotifyType=0, insertStateType=2, + selectedTrack=0, selectedSplit=0, newTrackOpen=false, newTrackKind=0, nextTrackId=0, + basePlayer=null, enemySkeleton=null, enemyPlayer=null, enemyParts=[], enemySkins=[], combatRuntime=null, + combatTick=0, + abilityGrantId=0, abilityActivationId=0, abilityPhase="ready", abilityCooldown=0.0, + comboWindowOpen=false, comboInputTag="", attackWas=false, comboCount=0, + playbackRate=1.0, slowWas=false, normalWas=false, fastWas=false, + playerSubject="01020304-0506-4708-890a-0b0c0d0e0f10", + enemySubject="11121314-1516-4718-991a-1b1c1d1e1f20", + playerHealth=100.0, enemyHealth=80.0, enemyReaction="none", +}; +foreach (key, value in editorUiDefaults) + if (!(key in combatEditor)) combatEditor[key] <- value; + function ns(seconds) { return (seconds * 1000000000.0).tointeger(); } function clampf(value, minimum, maximum) { return value < minimum ? minimum : (value > maximum ? maximum : value); } +function trackChoices() { + local choices = ""; + for (local i = 0; i < combatEditor.timeline.getTrackCount(); ++i) { + if (i > 0) choices += "\n"; + choices += combatEditor.timeline.getTrackLabel(i) + " [" + combatEditor.timeline.getTrackKind(i) + "]"; + } + return choices; +} + +function sectionSplitChoices() { + local choices = ""; + for (local i = 0; i < combatEditor.timeline.getSectionSplitCount(); ++i) { + if (i > 0) choices += "\n"; + choices += "Split " + (i + 1); + } + return choices == "" ? "No physical splits" : choices; +} + +function sectionSplitIndexAt(time) { + local nearest = 0; local distance = combatEditor.timeline.getDuration(); + for (local i = 0; i < combatEditor.timeline.getSectionSplitCount(); ++i) { + local delta = fabs(combatEditor.timeline.getSectionSplitTime(i) - time); + if (delta < distance) { nearest = i; distance = delta; } + } + return nearest; +} + +function trackKindChoices() { return "Animation\nGameplay\nEffect\nAudio\nCamera\nMovement\nTag\nCustom"; } +function trackKindAt(index) { + local kinds = ["animation", "gameplay", "effect", "audio", "camera", "movement", "tag", "custom"]; + return kinds[clampf(index, 0, kinds.len() - 1).tointeger()]; +} + +function insertTypeChoices(state) { + local choices = ""; + for (local i = 0; i < combatEditor.timeline.getInsertableTypeCount(state); ++i) { + if (i > 0) choices += "\n"; + choices += combatEditor.timeline.getInsertableTypeLabel(state, i); + } + return choices; +} + +function defaultPayloadForType(type) { + local payloads = { + ["combat:damage"]="{\"damageType\":\"Damage.Physical\",\"amount\":1}", + ["gameplay:event"]="{\"tag\":\"Gameplay.Event\"}", + ["presentation:vfx"]="{\"uri\":\"asset://vfx/effect\",\"lifetimeSeconds\":1.0}", + ["presentation:audio"]="{\"uri\":\"asset://audio/clip\"}", + ["presentation:camera"]="{\"cue\":\"camera:impact\"}", + ["collision:ignore-window"]="{\"channel\":\"default\"}", + ["combat:hitbox-window"]="{\"hitbox\":\"weapon.main\"}", + ["combat:invulnerability-window"]="{}", + ["gameplay:prefab-spawn"]="{\"uri\":\"asset://prefabs/object\"}", + ["input:combo-window"]="{\"input\":\"Ability.Attack\"}", + ["movement:root-motion-window"]="{\"mode\":\"animation\"}", + ["presentation:audio-state"]="{\"uri\":\"asset://audio/clip\"}", + ["presentation:vfx-state"]="{\"uri\":\"asset://vfx/effect\"}", + ["presentation:parameter-curve"]="{\"target\":\"audio:master-volume\",\"keys\":[]}", + }; + return type in payloads ? payloads[type] : "{}"; +} + function requireResult(result, context) { if (!result.ok) throw context + ": " + result.status.summary; return result.value; @@ -28,7 +122,7 @@ function requireResult(result, context) { function attackTimeline(duration) { return { - schema="eve.action.timeline", schemaVersion=3, + schema="eve.action.timeline", schemaVersion=4, actionId="combat:light-attack-kaykit", durationNs=ns(duration), animationUri="asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop", animationSections=[ @@ -55,27 +149,37 @@ function attackTimeline(duration) { { id="kaykit-track:gameplay", label="Gameplay", kind="gameplay", muted=false, locked=false, notifies=[ { id="kaykit-notify:damage", type="combat:damage", timeNs=ns(duration * 0.46), - payload={ damageType="Damage.Physical.Slash", amount=18 } }, + payload={ damageType="Damage.Physical.Slash", amount=18 }, enabled=true }, ], states=[ { id=HITBOX_ID, type="combat:hitbox-window", startNs=ns(duration * 0.30), - endNs=ns(duration * 0.62), payload={ hitbox="weapon.main" } }, + endNs=ns(duration * 0.62), payload={ hitbox="weapon.main" }, enabled=true }, { id="kaykit-state:combo", type="input:combo-window", startNs=ns(duration * 0.65), - endNs=ns(duration * 0.86), payload={ input="Ability.Combat.Attack.Light" } }, + endNs=ns(duration * 0.86), payload={ input="Ability.Combat.Attack.Light" }, enabled=true }, ] }, { id="kaykit-track:presentation", label="Presentation", kind="effect", muted=false, locked=false, notifies=[ { id="kaykit-notify:swing-audio", type="presentation:audio", timeNs=ns(duration * 0.32), - payload={ uri="asset://audio/sword-whoosh" } }, + payload={ uri="asset://audio/sword-whoosh" }, enabled=true }, { id="kaykit-notify:swing-vfx", type="presentation:vfx", timeNs=ns(duration * 0.34), - payload={ uri="asset://vfx/sword-arc" } }, + payload={ uri="asset://vfx/sword-arc", lifetimeSeconds=0.45 }, enabled=true }, { id="kaykit-notify:impact-camera", type="presentation:camera", timeNs=ns(duration * 0.46), - payload={ cue="combat.light-impact" } }, - ], states=[] }, + payload={ cue="combat:light-impact", positionAmplitude=0.08, + rotationAmplitude=1.8, fovAmplitude=2.0, + durationSeconds=0.24, seed=47 }, enabled=true }, + ], states=[ + { id="kaykit-state:volume-curve", type="presentation:parameter-curve", + startNs=ns(duration * 0.20), endNs=ns(duration * 0.75), + payload={ target="audio:master-volume", operation="multiply", keys=[ + { time=0.0, value=0.35, inTangent=0.0, outTangent=2.2, interpolation="cubic" }, + { time=0.35, value=1.0, inTangent=0.0, outTangent=0.0, interpolation="cubic" }, + { time=1.0, value=0.55, inTangent=-0.8, outTangent=0.0, interpolation="linear" }, + ] }, enabled=true }, + ] }, { id="kaykit-track:movement", label="Root Motion", kind="movement", muted=false, locked=false, notifies=[], states=[ { id="kaykit-state:root-motion", type="movement:root-motion-window", startNs=0, - endNs=ns(duration), payload={ mode="animation" } }, + endNs=ns(duration), payload={ mode="animation" }, enabled=true }, ] }, ], }; @@ -132,6 +236,21 @@ function buildCharacterPreview() { combatEditor.player.play(combatEditor.clip); combatEditor.player.setLoop(false); + local playerIdleIndex = findAnimation(combatEditor.generalLibrary, "Idle_A"); + if (playerIdleIndex < 0) throw "KayKit idle clip Idle_A was not found"; + local playerIdle = anim.newClipFromModel(combatEditor.generalLibrary, combatEditor.skeleton, playerIdleIndex); + playerIdle.setLoop(true); + combatEditor.basePlayer = anim.newPlayer(combatEditor.skeleton); + combatEditor.basePlayer.play(playerIdle); combatEditor.basePlayer.setLoop(true); + + combatEditor.enemySkeleton = anim.newSkeletonFromModel(combatEditor.knight); + local idleIndex = findAnimation(combatEditor.generalLibrary, "Idle_A"); + if (idleIndex < 0) throw "KayKit idle clip Idle_A was not found"; + local enemyIdle = anim.newClipFromModel(combatEditor.generalLibrary, combatEditor.enemySkeleton, idleIndex); + enemyIdle.setLoop(true); + combatEditor.enemyPlayer = anim.newPlayer(combatEditor.enemySkeleton); + combatEditor.enemyPlayer.play(enemyIdle); combatEditor.enemyPlayer.setLoop(true); + for (local i = 0; i < combatEditor.knight.getMeshCount(); ++i) { local part = model3d.createRenderable(gfx, combatEditor.knight, i); part.setPosition(0.0, 0.02, 0.0); part.setYaw(-0.25); @@ -140,6 +259,14 @@ function buildCharacterPreview() { if (combatEditor.knight.hasBones(i)) combatEditor.skins.push({ skin=anim.newSkinFromModel(combatEditor.knight, i, combatEditor.skeleton), part=part }); + + local enemyPart = model3d.createRenderable(gfx, combatEditor.knight, i); + enemyPart.setPosition(-1.65, 0.02, 0.0); enemyPart.setYaw(0.25); + enemyPart.setCastShadow(true); configurePreviewMaterial(enemyPart, combatEditor.knightTexture); + combatEditor.enemyParts.push(enemyPart); + if (combatEditor.knight.hasBones(i)) + combatEditor.enemySkins.push({ + skin=anim.newSkinFromModel(combatEditor.knight, i, combatEditor.enemySkeleton), part=enemyPart }); } // Import the weapon independently too; the same character + weapon + clip @@ -160,6 +287,13 @@ function buildCharacterPreview() { combatEditor.camera.setEye(3.25, 2.25, 4.25); combatEditor.camera.setTarget(0.1, 0.85, 0.0); combatEditor.camera.setUp(0.0, 1.0, 0.0); combatEditor.camera.setFov(40.0); combatEditor.camera.setAmbient(0.28, 0.32, 0.38); combatEditor.camera.setActive(true); + combatEditor.cameraController = eve.CameraController(); + combatEditor.cameraController.setCamera(combatEditor.camera); + combatEditor.cameraController.setMode("orbit"); + combatEditor.cameraController.setTarget(0.1, 0.85, 0.0); + combatEditor.cameraController.setFov(40.0); + combatEditor.cameraController.setSmooth(30.0); + combatEditor.cameraController.setActionCuesEnabled(true); gfx.setBackgroundColor(0.10, 0.13, 0.19, 1.0); combatEditor.keyLight = eve.Light3D(); combatEditor.keyLight.setType("dir"); combatEditor.keyLight.setDirection(-0.45, 1.0, 0.35); @@ -169,29 +303,295 @@ function buildCharacterPreview() { combatEditor.fillLight.setColor(0.55, 0.70, 1.0, 1.3); } -function startMontageRuntime() { +function buildCombatPlaytest() { + combatEditor.combatRuntime = requireResult(eve.Combat().newRuntime(), "Create combat playtest runtime"); + requireResult(combatEditor.combatRuntime.registerFighter( + combatEditor.playerSubject, "player", 0.0, 0.0, 100.0, 40.0, 3.5, 18.0), "Register player"); + requireResult(combatEditor.combatRuntime.registerFighter( + combatEditor.enemySubject, "enemy", -1.65, 0.0, 80.0, 30.0, 0.1, 0.1), "Register enemy"); + local timelineJson = requireResult(combatEditor.timeline.snapshotJson(), "Snapshot authored ability"); + requireResult(combatEditor.combatRuntime.registerTimelineAbility( + "combat-ability:editor-light", timelineJson, 0.25, "per-execution", + "exclusive-replaceable", "Ability.Combat.Attack.Light"), "Register authored ability"); + local grant = requireResult(combatEditor.combatRuntime.grantAbility( + "fighter:player", "combat-ability:editor-light"), "Grant authored ability"); + combatEditor.abilityGrantId = grant.grantId; +} + +function activatePlaytestAbility() { + local activated = combatEditor.combatRuntime.activateAbility( + combatEditor.abilityGrantId, combatEditor.combatTick); + if (!activated.ok) { combatEditor.status = activated.status.summary; return false; } + combatEditor.abilityActivationId = activated.value.activationId; + combatEditor.abilityPhase = "requested"; + return true; +} + +function activateTriggeredAbility(eventTag) { + local matches = combatEditor.combatRuntime.matchingAbilities("fighter:player", eventTag); + if (!matches.ok || matches.value.len() == 0) { + combatEditor.status = matches.ok ? "No Ability matches " + eventTag : matches.status.summary; + return false; + } + local activated = combatEditor.combatRuntime.activateAbility(matches.value[0], combatEditor.combatTick); + if (!activated.ok) { combatEditor.status = activated.status.summary; return false; } + combatEditor.abilityActivationId = activated.value.activationId; + combatEditor.abilityPhase = "requested"; + combatEditor.comboCount += 1; + requireResult(combatEditor.timeline.replayRuntimeCrossFade(), "Cross-fade combo montage"); + combatEditor.status = "Combo accepted · " + eventTag + " · chain " + combatEditor.comboCount; + return true; +} + +function hotReloadPlaytestAbility() { + if (combatEditor.combatRuntime == null || combatEditor.timeline == null) return true; + local snapshot = combatEditor.timeline.snapshotJson(); + if (!snapshot.ok) { combatEditor.status = snapshot.status.summary; return false; } + local replaced = combatEditor.combatRuntime.replaceTimelineAbility( + "combat-ability:editor-light", snapshot.value, 0.25, "per-execution", + "exclusive-replaceable", "Ability.Combat.Attack.Light"); + if (!replaced.ok) { combatEditor.status = replaced.status.summary; return false; } + return true; +} + +function setPlaybackRate(rate) { + local changed = combatEditor.timeline.setRuntimeRate(rate); + if (!changed.ok) { combatEditor.status = changed.status.summary; return false; } + combatEditor.playbackRate = rate; + combatEditor.status = format("Runtime playback · %.1fx", rate); + return true; +} + +function advanceCombatPlaytest(dt) { + local x = 0.0; local z = 0.0; + if (keyboard.isDown("a") || keyboard.isDown("A")) x -= 1.0; + if (keyboard.isDown("d") || keyboard.isDown("D")) x += 1.0; + if (keyboard.isDown("w") || keyboard.isDown("W")) z -= 1.0; + if (keyboard.isDown("s") || keyboard.isDown("S")) z += 1.0; + if (x != 0.0 || z != 0.0) + requireResult(combatEditor.combatRuntime.setMoveIntent(combatEditor.playerSubject, x, z, 1.0), + "Set playtest movement"); + else + requireResult(combatEditor.combatRuntime.stop(combatEditor.playerSubject), "Stop playtest movement"); + combatEditor.combatTick += 1; + requireResult(combatEditor.combatRuntime.advance(combatEditor.combatTick, dt), "Advance combat playtest"); + local abilities = requireResult(combatEditor.combatRuntime.advanceAbilities( + combatEditor.combatTick, dt), "Advance authored ability"); + if (abilities.advances.len() > 0) + combatEditor.abilityPhase = abilities.advances[abilities.advances.len() - 1].phase; + local grant = requireResult(combatEditor.combatRuntime.abilityGrant( + combatEditor.abilityGrantId), "Read authored ability"); + combatEditor.abilityCooldown = grant.cooldownSeconds; + if (abilities.activeCount == 0 && combatEditor.abilityCooldown <= 0.0) + combatEditor.abilityPhase = "ready"; + local playerState = requireResult(combatEditor.combatRuntime.state(combatEditor.playerSubject), "Read player"); + local enemyState = requireResult(combatEditor.combatRuntime.state(combatEditor.enemySubject), "Read enemy"); + combatEditor.playerHealth = playerState.health; combatEditor.enemyHealth = enemyState.health; + foreach (part in combatEditor.knightParts) + part.setPosition(playerState.position.x, 0.02, playerState.position.z); + foreach (part in combatEditor.enemyParts) + part.setPosition(enemyState.position.x, 0.02, enemyState.position.z); +} + +function routeRuntimeCombatEvents() { + for (local i = 0; i < combatEditor.timeline.getRuntimeEventCount(); ++i) { + local kind = combatEditor.timeline.getRuntimeEventKind(i); + local type = combatEditor.timeline.getRuntimeEventType(i); + if (type == "input:combo-window") { + if (kind == "state_enter") { + combatEditor.comboWindowOpen = true; + combatEditor.comboInputTag = combatEditor.timeline.getItemPayloadText( + combatEditor.timeline.getRuntimeEventId(i), "input", ""); + } else if (kind == "state_exit") { + combatEditor.comboWindowOpen = false; + combatEditor.comboInputTag = ""; + } + continue; + } + if (kind != "notify" || type != "combat:damage") continue; + local outcome = combatEditor.combatRuntime.applyTimelineDamage( + combatEditor.playerSubject, combatEditor.enemySubject, + combatEditor.timeline.getRuntimeEventPayloadJson(i)); + if (!outcome.ok) { combatEditor.status = outcome.status.summary; continue; } + combatEditor.enemyHealth = outcome.value.health; + combatEditor.enemyReaction = outcome.value.reaction; + combatEditor.lastEvent = combatEditor.timeline.getRuntimeEventId(i) + " · " + + outcome.value.appliedHealthDamage + " damage · " + outcome.value.reaction; + print("combat-action-editor: runtime damage=" + outcome.value.appliedHealthDamage + + " targetHealth=" + outcome.value.health + " reaction=" + outcome.value.reaction + "\n"); + } +} + +function startMontageRuntime(timeline) { local idle = findAnimation(combatEditor.generalLibrary, "Idle_A"); local attack = findAnimation(combatEditor.meleeLibrary, "Melee_1H_Attack_Chop"); local block = findAnimation(combatEditor.meleeLibrary, "Melee_Block"); if (idle < 0 || attack < 0 || block < 0) throw "KayKit montage source clips are incomplete"; - requireResult(combatEditor.timeline.registerRuntimeClip( + requireResult(timeline.registerRuntimeClip( "asset://kaykit/Rig_Medium_General.glb#Idle_A", combatEditor.generalLibrary, combatEditor.skeleton, idle), "Register anticipation clip"); - requireResult(combatEditor.timeline.registerRuntimeClip( + requireResult(timeline.registerRuntimeClip( "asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop", combatEditor.meleeLibrary, combatEditor.skeleton, attack), "Register strike clip"); - requireResult(combatEditor.timeline.registerRuntimeClip( + requireResult(timeline.registerRuntimeClip( "asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block", combatEditor.meleeLibrary, combatEditor.skeleton, block), "Register recovery clip"); - requireResult(combatEditor.timeline.beginRuntime(combatEditor.skeleton), "Start action montage runtime"); + requireResult(timeline.setRuntimeBlockExternallyHandled("combat:damage", true), + "Route combat damage to playtest runtime"); + requireResult(timeline.setRuntimeBlockExternallyHandled("combat:hitbox-window", true), + "Route hitbox window to playtest UI"); + requireResult(timeline.setRuntimeBlockExternallyHandled("input:combo-window", true), + "Route combo window to playtest UI"); + requireResult(timeline.setRuntimeBlockExternallyHandled("presentation:audio", true), + "Route placeholder audio to playtest UI"); + requireResult(timeline.setRuntimeBlockExternallyHandled("presentation:vfx", true), + "Route placeholder VFX to playtest UI"); + requireResult(timeline.beginRuntime(combatEditor.skeleton), "Start action montage runtime"); +} + +function refreshEditedMontageClip() { + requireResult(combatEditor.clipEditor.writeRuntimeClip(combatEditor.clip, combatEditor.skeleton), + "Rebuild edited KayKit clip"); + requireResult(combatEditor.timeline.replaceRuntimeClip( + "asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop", combatEditor.clip), + "Hot-reload edited clip into montage"); + requireResult(combatEditor.timeline.jumpRuntimeSeconds(combatEditor.timeline.getPreviewTime()), + "Resample edited montage pose"); +} + +function openActionDocument(assetGuid, title, resourceUri, timelineData) { + local timeline = requireResult(combatEditor.actionModule.openDocument( + ".", assetGuid, title, resourceUri, timelineData), "Open " + title); + startMontageRuntime(timeline); + combatEditor.tabs.push({ title=title, assetGuid=assetGuid, resourceUri=resourceUri, editor=timeline }); + return timeline; +} + +function findOpenAsset(assetGuid) { + for (local i = 0; i < combatEditor.tabs.len(); ++i) + if (combatEditor.tabs[i].assetGuid == assetGuid) return i; + return -1; +} + +function openBundledDocument(assetGuid) { + local existing = findOpenAsset(assetGuid); + if (existing >= 0) { activateTab(existing); return; } + local timelineData = attackTimeline(combatEditor.clip.getDuration()); + if (assetGuid == "asset.kaykit.light-attack") { + openActionDocument(assetGuid, "Light Attack", "content://Actions/KayKitLightAttack.action", timelineData); + } else { + timelineData.actionId = "combat:follow-up-kaykit"; + timelineData.metadata.variant <- "follow-up"; + openActionDocument(assetGuid, "Follow-up", "content://Actions/KayKitFollowUp.action", timelineData); + } + activateTab(combatEditor.tabs.len() - 1); +} + +function saveActiveDocument() { + local tab = combatEditor.tabs[combatEditor.activeTab]; + local saved = tab.editor.saveDocument(); + if (!saved.ok) { combatEditor.status = "Save failed · " + saved.status.summary; return; } + local indexed = combatEditor.assetCatalog.registerDocument(tab.assetGuid, tab.resourceUri); + if (!indexed.ok) { + combatEditor.status = "Saved, but AssetDB registration failed · " + indexed.status.summary; + return; + } + combatEditor.assetCatalog.refresh(combatEditor.assetSearch); + if (!hotReloadPlaytestAbility()) return; + combatEditor.status = "Saved and indexed · " + tab.resourceUri; + combatEditor.closeArmed = -1; combatEditor.tabUiSignature = ""; +} + +function mountAssetPicker() { + ui.beginBuild(); ui.beginWindow("Open Montage Asset", "root"); + ui.searchField("Search Content paths", combatEditor.assetSearch, "asset-search"); + ui.text(combatEditor.assetCatalog.getAssetCount() + " validated Montage assets", "asset-count"); + ui.beginScrollList("asset-results", 315.0, 25.0); + for (local i = 0; i < combatEditor.assetCatalog.getAssetCount(); ++i) + ui.listItem(combatEditor.assetCatalog.getAssetTitle(i) + " · " + + combatEditor.assetCatalog.getAssetUri(i), "pick-asset-" + i); + ui.end(); + ui.beginToolbar("asset-picker-actions"); ui.iconButton("close", "Cancel", "close-asset-picker"); ui.end(); + ui.end(); ui.mountBuildAs("action.asset-picker"); ui.select("action.asset-picker"); + ui.setHostOverlay(true); ui.setHostModal(true); ui.setHostMovable(false); ui.setHostResizable(false); + ui.setHostPos(330.0, 150.0, 0.0, 0.0); ui.setHostSize(620.0, 420.0); ui.setHostVisible(true); +} + +function showAssetPicker() { + local refreshed = combatEditor.assetCatalog.refresh(combatEditor.assetSearch); + combatEditor.status = refreshed.ok ? "AssetDB query · " + refreshed.value + " Montage assets" + : "AssetDB query failed · " + refreshed.status.summary; + combatEditor.assetPickerOpen = true; + mountAssetPicker(); +} + +function hideAssetPicker() { + combatEditor.assetPickerOpen = false; + ui.select("action.asset-picker"); ui.setHostVisible(false); ui.setHostModal(false); +} + +function openCatalogAsset(index) { + if (index < 0 || index >= combatEditor.assetCatalog.getAssetCount()) return; + local guid = combatEditor.assetCatalog.getAssetGuid(index); + local existing = findOpenAsset(guid); + if (existing >= 0) { hideAssetPicker(); activateTab(existing); return; } + local title = combatEditor.assetCatalog.getAssetTitle(index); + local uri = combatEditor.assetCatalog.getAssetUri(index); + openActionDocument(guid, title, uri, attackTimeline(combatEditor.clip.getDuration())); + hideAssetPicker(); activateTab(combatEditor.tabs.len() - 1); +} + +function activateTab(index) { + if (index < 0 || index >= combatEditor.tabs.len()) return; + combatEditor.activeTab = index; + combatEditor.timeline = combatEditor.tabs[index].editor; + combatEditor.closeArmed = -1; + requireResult(combatEditor.timeline.setViewport(combatEditor.workspace.getRegionW("bottom") - 20.0, + 36.0, 145.0), "Configure active timeline viewport"); + requireResult(combatEditor.timeline.setSnapSeconds(1.0 / 30.0), "Configure active frame snapping"); + requireResult(combatEditor.timeline.setRuntimeRate(combatEditor.playbackRate), + "Restore montage playback rate"); + combatEditor.status = "Active document · " + combatEditor.tabs[index].title; + combatEditor.tabUiSignature = ""; +} + +function closeTab(index) { + if (index < 0 || index >= combatEditor.tabs.len()) return; + if (combatEditor.tabs.len() == 1) { + combatEditor.status = "Keep at least one montage document open"; + return; + } + local tab = combatEditor.tabs[index]; + if (tab.editor.isDirty() && combatEditor.closeArmed != index) { + combatEditor.closeArmed = index; + combatEditor.status = "Unsaved changes in " + tab.title + " · click close again to discard"; + combatEditor.tabUiSignature = ""; + return; + } + combatEditor.tabs.remove(index); + local next = combatEditor.activeTab; + if (index < next) next -= 1; + if (next >= combatEditor.tabs.len()) next = combatEditor.tabs.len() - 1; + activateTab(next); } function buildWorkspace() { combatEditor.workspace = editor.newWorkspace("kaykit.combat", "KayKit Combat Action Editor"); + combatEditor.actionModule = eve.ActionEditorModule(); + combatEditor.assetCatalog = requireResult(combatEditor.actionModule.createAssetCatalog("."), + "Create Action AssetDB browser"); + requireResult(combatEditor.assetCatalog.refresh(""), "Scan Montage assets"); + combatEditor.tabs = []; local timelineData = attackTimeline(combatEditor.clip.getDuration()); - combatEditor.timeline = requireResult( - eve.ActionEditorModule().create("asset.kaykit.light-attack", timelineData), - "Create action timeline editor"); + openActionDocument("asset.kaykit.light-attack", "Light Attack", + "content://Actions/KayKitLightAttack.action", timelineData); + local alternate = attackTimeline(combatEditor.clip.getDuration()); + alternate.actionId = "combat:follow-up-kaykit"; + alternate.metadata.variant <- "follow-up"; + openActionDocument("asset.kaykit.follow-up", "Follow-up", + "content://Actions/KayKitFollowUp.action", alternate); + combatEditor.activeTab = 0; + combatEditor.timeline = combatEditor.tabs[0].editor; combatEditor.clipEditor = requireResult( eve.AnimationEditorModule().create("asset.kaykit.light-attack.clip"), "Create KayKit clip editor"); @@ -203,7 +603,6 @@ function buildWorkspace() { combatEditor.workspace.setRegionSize("right", 340.0 / dpi); combatEditor.workspace.setRegionSize("bottom", 330.0 / dpi); combatEditor.workspace.layout(win.getWidth().tofloat() / dpi, win.getHeight().tofloat() / dpi); - startMontageRuntime(); } function panelAssets() { @@ -222,14 +621,189 @@ function panelAssets() { } function panelPreview() { - ui.text("RMB orbit · wheel zoom · timeline drives pose", "preview-help"); + ui.text("WASD move · RMB orbit · wheel zoom", "preview-help"); ui.text("", "preview-status"); + ui.text("", "combat-status"); ui.viewport("combat-preview", combatEditor.workspace.getRegionW("center") - 20.0, combatEditor.workspace.getRegionH("center") - 92.0); } function panelInspector() { - ui.text("Joint Inspector", "inspector-title"); ui.text("", "action-uri"); ui.text("", "revision"); + ui.text("Inspector", "inspector-title"); + ui.beginToolbar("inspector-tabs"); + ui.iconButton("bone", "Joint", "inspect-joint"); ui.setItemSelected(combatEditor.inspectorMode == "joint"); + ui.iconButton("layers", "Action Block", "inspect-action"); ui.setItemSelected(combatEditor.inspectorMode == "action"); + ui.iconButton("list", "Track", "inspect-track"); ui.setItemSelected(combatEditor.inspectorMode == "track"); + ui.iconButton("settings", "Montage", "inspect-montage"); ui.setItemSelected(combatEditor.inspectorMode == "montage"); + ui.end(); + ui.text("", "action-uri"); ui.text("", "revision"); + if (combatEditor.inspectorMode == "montage") { + ui.text("Montage Settings", "montage-title"); + ui.slider("Play Rate", 1.0, 0.05, 4.0, "montage-rate"); + ui.checkbox("Looping", false, "montage-looping"); + ui.checkbox("Foot IK", false, "montage-foot-ik"); + ui.slider("Animation Layer", 0.0, 0.0, 8.0, "montage-layer"); + ui.slider("Default Blend In", 0.0, 0.0, combatEditor.timeline.getDuration(), "montage-blend-in"); + ui.slider("Default Blend Out", 0.0, 0.0, combatEditor.timeline.getDuration(), "montage-blend-out"); + ui.slider("Blend Out Offset", 0.0, -combatEditor.timeline.getDuration(), + combatEditor.timeline.getDuration(), "montage-blend-offset"); + ui.text("Root Motion", "montage-root-title"); + ui.checkbox("Horizontal", true, "montage-root-horizontal"); + ui.checkbox("Vertical", true, "montage-root-vertical"); + ui.checkbox("Rotation", true, "montage-root-rotation"); + ui.text("Layer Bone Mask", "montage-mask-title"); + ui.slider("Layer Weight", 1.0, 0.0, 1.0, "montage-runtime-weight"); + ui.checkbox("Additive", false, "montage-runtime-additive"); + ui.slider("Selected Joint", 1.0, 0.0, 1.0, "montage-mask-weight"); + ui.checkbox("Include Children", true, "montage-mask-children"); + ui.beginToolbar("montage-mask-tools"); + ui.iconButton("save", "Apply", "apply-montage-mask"); + ui.iconButton("close", "Clear", "clear-montage-mask"); + ui.end(); + ui.text("Physical Sections", "physical-sections-title"); + ui.combo("Split", sectionSplitChoices(), combatEditor.selectedSplit, "section-split-selector"); + ui.slider("Split Time", 0.5, 0.0, combatEditor.timeline.getDuration(), "section-split-time"); + ui.beginToolbar("section-split-tools"); + ui.iconButton("plus", "Add at Playhead", "add-section-split"); + ui.iconButton("trash", "Delete Split", "delete-section-split"); + ui.end(); + ui.beginToolbar("montage-tools"); ui.iconButton("undo", "", "montage-undo"); + ui.iconButton("redo", "", "montage-redo"); ui.end(); + ui.textWrapped("Settings update the authoritative asset and the running preview without rebuilding clips.", + 245.0, "montage-help"); + return; + } + if (combatEditor.inspectorMode == "track") { + if (combatEditor.newTrackOpen) { + ui.text("New Track", "new-track-title"); + ui.inputText("Name", "New Track", "new-track-label"); + ui.combo("Kind", trackKindChoices(), combatEditor.newTrackKind, "new-track-kind"); + ui.beginToolbar("new-track-actions"); ui.iconButton("plus", "Create", "add-track"); + ui.iconButton("close", "Cancel", "cancel-new-track"); ui.end(); + ui.textWrapped("Track creation is validated and committed as one undoable transaction.", + 245.0, "new-track-help"); + return; + } + ui.text("Track Inspector", "track-title"); + ui.combo("Track", trackChoices(), combatEditor.selectedTrack, "track-selector"); + ui.text("", "track-id"); ui.text("", "track-kind"); + ui.inputText("Name", "", "track-label"); + ui.checkbox("Muted", false, "track-muted"); ui.checkbox("Locked", false, "track-locked"); + ui.beginToolbar("track-tools"); ui.iconButton("plus", "New Track", "new-track"); + ui.iconButton("copy", "Copy Track", "copy-track"); + ui.iconButton("clipboard", "Paste Track", "paste-track"); + ui.iconButton("trash", "Delete Track", "delete-track"); + ui.iconButton("undo", "", "track-undo"); ui.iconButton("redo", "", "track-redo"); ui.end(); + ui.textWrapped("Muted tracks do not emit events. Locked tracks reject item edits and paste operations.", + 245.0, "track-help"); + return; + } + if (combatEditor.inspectorMode == "action") { + if (combatEditor.newSectionOpen) { + local sectionStart = combatEditor.timeline.getPreviewTime(); + local sectionEnd = clampf(sectionStart + 0.25, sectionStart, combatEditor.timeline.getDuration()); + ui.text("New Animation Section", "new-section-title"); + ui.inputText("Animation URI", combatEditor.timeline.getAnimationUri(), "new-section-uri"); + ui.slider("Start", sectionStart, 0.0, combatEditor.timeline.getDuration(), "new-section-start"); + ui.slider("End", sectionEnd, 0.0, combatEditor.timeline.getDuration(), "new-section-end"); + ui.slider("Blend In", 0.10, 0.0, combatEditor.timeline.getDuration(), "new-section-blend"); + ui.beginToolbar("new-section-actions"); ui.iconButton("plus", "Create", "add-section"); + ui.iconButton("close", "Cancel", "cancel-new-section"); ui.end(); + ui.textWrapped("Sections use exact timeline time and are rejected if they overlap or exceed the montage.", + 245.0, "new-section-help"); + return; + } + if (combatEditor.insertPanelOpen) { + ui.text("New Block at Playhead", "insert-title"); + ui.combo("Track", trackChoices(), combatEditor.insertTrack, "insert-track"); + ui.combo("Instant", insertTypeChoices(false), combatEditor.insertNotifyType, "insert-notify-type"); + ui.inputText("Payload", defaultPayloadForType( + combatEditor.timeline.getInsertableType(false, combatEditor.insertNotifyType)), "insert-notify-payload"); + ui.iconButton("plus", "Add Notify", "insert-notify"); + ui.separator("insert-separator"); + ui.combo("State", insertTypeChoices(true), combatEditor.insertStateType, "insert-state-type"); + ui.slider("Duration", 0.25, 1.0 / 30.0, combatEditor.timeline.getDuration(), "insert-state-duration"); + ui.inputText("Payload", defaultPayloadForType( + combatEditor.timeline.getInsertableType(true, combatEditor.insertStateType)), "insert-state-payload"); + ui.beginToolbar("insert-actions"); ui.iconButton("plus", "Add State", "insert-state"); + ui.iconButton("close", "Cancel", "cancel-insert"); ui.end(); + ui.textWrapped("Types come from the native registry. Payload contracts are validated before commit.", + 245.0, "insert-help"); + return; + } + ui.text("Action Block", "action-block-title"); ui.text("No Action Block selected", "action-block-id"); + ui.text("", "action-block-kind"); + ui.text("Selection: 0 blocks", "action-selection-summary"); + ui.beginToolbar("selection-tools"); + ui.iconButton("align-left", "Align Starts", "align-selection-start"); + ui.iconButton("align-right", "Align Ends", "align-selection-end"); + ui.end(); + ui.checkbox("Enabled", true, "action-enabled"); + ui.text("Type", "action-type-label"); ui.inputText("##Type", "", "action-type"); + ui.text("Resource", "typed-resource-title"); + ui.inputText("URI", "", "payload-uri"); + ui.text("Audio", "typed-audio-title"); + ui.inputText("Random URIs (; separated)", "", "audio-random-uris"); + ui.slider("Volume", 1.0, 0.0, 2.0, "audio-volume"); + ui.slider("Pitch", 1.0, 0.05, 4.0, "audio-pitch"); + ui.slider("Pitch Variation", 0.0, 0.0, 0.5, "audio-random-pitch"); + ui.slider("Spatial Blend", 1.0, 0.0, 1.0, "audio-spatial-blend"); + ui.slider("Min Distance", 1.0, 0.01, 50.0, "audio-min-distance"); + ui.slider("Max Distance", 35.0, 0.1, 200.0, "audio-max-distance"); + ui.checkbox("Looping", false, "audio-looping"); + ui.checkbox("Fade Out on Exit", true, "audio-fade-out"); + ui.slider("Fade Duration", 0.1, 0.01, 2.0, "audio-fade-duration"); + ui.text("VFX", "typed-vfx-title"); + ui.combo("Stop", "Stop Emitting\nClear Immediately", 0, "vfx-stop"); + ui.checkbox("Sync Rate", true, "vfx-sync-rate"); + ui.slider("Clip Start", 0.0, 0.0, combatEditor.timeline.getDuration(), "vfx-clip-start"); + ui.slider("Clip End", 0.5, 0.0, combatEditor.timeline.getDuration(), "vfx-clip-end"); + ui.iconButton("move", "Fit Block to Clip", "vfx-fit-block"); + ui.iconButton("sliders", "Fit Clip to Block", "vfx-fit-clip"); + ui.iconButton("refresh", "Use Asset Duration", "vfx-use-natural"); + ui.slider("Lifetime", 1.0, 0.01, 10.0, "vfx-lifetime"); + ui.text("Prefab", "typed-prefab-title"); + ui.combo("Lifecycle", "Recycle on Exit\nCustom Duration\nIndependent", 0, "prefab-lifecycle"); + ui.slider("Custom Duration", 1.0, 0.01, 10.0, "prefab-duration"); + ui.text("Spatial Attachment", "typed-spatial-title"); + ui.combo("Attachment", "Follow Target\nFollow Position\nWorld at Start", 0, "spatial-attachment"); + ui.combo("Anchor", "Source\nTarget", 0, "spatial-target"); + ui.slider("Target Index", 0.0, 0.0, 8.0, "spatial-target-index"); + ui.inputText("Bone", "", "spatial-bone"); + ui.text("Position Offset", "spatial-position-title"); + ui.slider("X", 0.0, -5.0, 5.0, "spatial-position-x"); + ui.slider("Y", 0.0, -5.0, 5.0, "spatial-position-y"); + ui.slider("Z", 0.0, -5.0, 5.0, "spatial-position-z"); + ui.text("Rotation Offset", "spatial-rotation-title"); + ui.slider("X", 0.0, -180.0, 180.0, "spatial-rotation-x"); + ui.slider("Y", 0.0, -180.0, 180.0, "spatial-rotation-y"); + ui.slider("Z", 0.0, -180.0, 180.0, "spatial-rotation-z"); + ui.text("Spawn Scale", "spatial-scale-title"); + ui.slider("X", 1.0, 0.05, 4.0, "spatial-scale-x"); + ui.slider("Y", 1.0, 0.05, 4.0, "spatial-scale-y"); + ui.slider("Z", 1.0, 0.05, 4.0, "spatial-scale-z"); + ui.setItemSize(300.0, 0.0); + ui.text("Advanced Payload JSON", "action-payload-label"); ui.inputText("##Payload", "{}", "action-payload"); + ui.setItemSize(300.0, 0.0); + ui.slider("Start", 0.0, 0.0, combatEditor.timeline.getDuration(), "action-start"); + ui.slider("End", 0.0, 0.0, combatEditor.timeline.getDuration(), "action-end"); + ui.text("Animation Section", "section-fields-title"); + ui.inputText("Animation URI", "", "section-uri"); + ui.slider("Blend In", 0.0, 0.0, combatEditor.timeline.getDuration(), "section-blend"); + ui.slider("Source Start", 0.0, 0.0, combatEditor.timeline.getDuration(), "section-source-start"); + ui.slider("Source End", 0.0, 0.0, combatEditor.timeline.getDuration(), "section-source-end"); + ui.combo("Blend Curve", "Linear\nEase In Out", 1, "section-curve"); + ui.beginToolbar("action-tools"); ui.iconButton("plus", "New Block", "new-action"); + ui.iconButton("layers", "New Section", "new-section"); + ui.iconButton("copy", "Copy", "copy-action"); + ui.iconButton("clipboard", "Paste at playhead", "paste-action"); + ui.iconButton("trash", "Delete Block", "delete-action"); + ui.iconButton("undo", "", "action-undo"); ui.iconButton("redo", "", "action-redo"); ui.end(); + ui.textWrapped("Timing edits are validated and committed as undoable timeline transactions.", + 245.0, "action-help"); + return; + } + ui.text("Joint Transform", "joint-title"); ui.text("", "selected-item"); ui.separator("inspector-sep-1"); ui.text("Position", "position-label"); ui.slider("X", 0.0, -2.0, 2.0, "position-x"); ui.slider("Y", 0.0, -2.0, 2.0, "position-y"); @@ -247,10 +821,122 @@ function panelInspector() { 245.0, "interaction-help"); } +function selectedActionIndex() { + for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i) + if (combatEditor.timeline.getItemSelected(i)) return i; + return -1; +} + +function selectedActionCount() { + local count = 0; + for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i) + if (combatEditor.timeline.getItemSelected(i)) count += 1; + return count; +} + +function selectedActionRange() { + local start = combatEditor.timeline.getDuration(); local finish = 0.0; local found = false; + for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i) { + if (!combatEditor.timeline.getItemSelected(i)) continue; + local id = combatEditor.timeline.getItemId(i); + local itemStart = combatEditor.timeline.getItemStart(id); + local itemEnd = combatEditor.timeline.getItemEnd(id); + if (itemStart < start) start = itemStart; + if (itemEnd > finish) finish = itemEnd; + found = true; + } + return found ? [start, finish] : [0.0, 0.0]; +} + +function animationSectionIndex(itemId) { + for (local i = 0; i < combatEditor.timeline.getAnimationSectionCount(); ++i) + if (combatEditor.timeline.getAnimationSectionId(i) == itemId) return i; + return -1; +} + +function blendCurveIndex(name) { return name == "linear" ? 0 : 1; } +function blendCurveAt(index) { return index == 0 ? "linear" : "ease-in-out"; } +function isAudioType(type) { return type == "presentation:audio" || type == "presentation:audio-state"; } +function isVfxType(type) { return type == "presentation:vfx" || type == "presentation:vfx-state"; } +function isPrefabType(type) { return type == "gameplay:prefab-spawn"; } +function isSpatialType(type) { return isAudioType(type) || isVfxType(type) || isPrefabType(type); } +function attachmentIndex(value) { + return value == "follow_position_only" ? 1 : (value == "world_transform_at_start" ? 2 : 0); +} +function attachmentAt(index) { + return index == 1 ? "follow_position_only" : (index == 2 ? "world_transform_at_start" : "follow_target"); +} +function lifecycleIndex(value) { + return value == "custom_duration" ? 1 : (value == "independent" ? 2 : 0); +} +function lifecycleAt(index) { + return index == 1 ? "custom_duration" : (index == 2 ? "independent" : "recycle_on_block_exit"); +} +function stopBehaviorIndex(value) { return value == "clear_immediately" ? 1 : 0; } +function stopBehaviorAt(index) { return index == 1 ? "clear_immediately" : "stop_emitting"; } + +function setTypedPayloadVisibility(type, single) { + local audio = single && isAudioType(type); + local vfx = single && isVfxType(type); + local prefab = single && isPrefabType(type); + local spatial = single && isSpatialType(type); + foreach (field in ["typed-resource-title", "payload-uri"]) ui.setVisible(field, spatial); + foreach (field in ["typed-audio-title", "audio-random-uris", "audio-volume", "audio-pitch", + "audio-random-pitch", "audio-spatial-blend", "audio-min-distance", "audio-max-distance", + "audio-fade-out", "audio-fade-duration"]) + ui.setVisible(field, audio); + ui.setVisible("audio-looping", audio && type == "presentation:audio-state"); + foreach (field in ["typed-vfx-title", "vfx-stop", "vfx-sync-rate", "vfx-clip-start", "vfx-clip-end", + "vfx-fit-block", "vfx-fit-clip", "vfx-use-natural"]) + ui.setVisible(field, vfx); + ui.setVisible("vfx-fit-block", vfx && type == "presentation:vfx-state"); + ui.setVisible("vfx-fit-clip", vfx && type == "presentation:vfx-state"); + ui.setVisible("vfx-use-natural", vfx && type == "presentation:vfx-state" && + combatEditor.timeline.hasVfxDurationProvider()); + ui.setVisible("vfx-lifetime", vfx && type == "presentation:vfx"); + foreach (field in ["typed-prefab-title", "prefab-lifecycle", "prefab-duration"]) + ui.setVisible(field, prefab); + foreach (field in ["typed-spatial-title", "spatial-attachment", "spatial-target", "spatial-target-index", + "spatial-bone", "spatial-position-title", "spatial-position-x", "spatial-position-y", + "spatial-position-z", "spatial-rotation-title", "spatial-rotation-x", "spatial-rotation-y", + "spatial-rotation-z", "spatial-scale-title", "spatial-scale-x", "spatial-scale-y", + "spatial-scale-z"]) ui.setVisible(field, spatial); +} + +function commitPayloadVector(field, prefix) { + return combatEditor.timeline.setItemPayloadVector3(combatEditor.selectedActionId, field, + ui.getValue(prefix + "-x"), ui.getValue(prefix + "-y"), ui.getValue(prefix + "-z")); +} + +function commitMontageSettings() { + return combatEditor.timeline.setMontageSettings( + ui.getValue("montage-rate"), ui.getChecked("montage-looping"), ui.getChecked("montage-foot-ik"), + ui.getValue("montage-layer").tointeger(), ui.getValue("montage-blend-in"), + ui.getValue("montage-blend-out"), ui.getValue("montage-blend-offset"), + ui.getChecked("montage-root-horizontal"), ui.getChecked("montage-root-vertical"), + ui.getChecked("montage-root-rotation")); +} + function panelTimeline() { - ui.beginRow("transport", 8.0); ui.button("Play / Pause", "play-pause"); - ui.button("Restart", "restart"); ui.button("Strike", "jump-strike"); - ui.button("Blend Out", "blend-out"); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end(); + ui.beginToolbar("documents"); + ui.iconButton("folder-open", "Open Asset", "open-document"); + ui.iconButton("save", "Save", "save-document"); + for (local i = 0; i < combatEditor.tabs.len(); ++i) { + local tab = combatEditor.tabs[i]; + ui.iconButton("file", tab.title + (tab.editor.isDirty() ? " *" : ""), "tab-" + i); + ui.setItemSelected(i == combatEditor.activeTab); + ui.iconButton("close", combatEditor.closeArmed == i ? "Discard" : "", "close-tab-" + i); + } + ui.end(); + ui.beginToolbar("transport"); + ui.iconButton("home", "", "first-frame"); ui.iconButton("chevron-left", "", "previous-frame"); + ui.iconButton("play", "", "play-pause"); ui.iconButton("stop", "", "stop"); + ui.iconButton("chevron-right", "", "next-frame"); ui.iconButton("refresh", "", "last-frame"); + ui.iconButton("layers", "Strike", "jump-strike"); ui.iconButton("close", "Blend Out", "blend-out"); + ui.iconButton("chevron-left", "0.5x", "speed-slow"); + ui.iconButton("play", "1.0x", "speed-normal"); + ui.iconButton("chevron-right", "1.5x", "speed-fast"); + ui.iconButton("undo", "", "undo"); ui.iconButton("redo", "", "redo"); ui.end(); ui.text("Animation keys · click a diamond to select, empty space to scrub", "bone-timeline-label"); ui.viewport("bone-timeline", combatEditor.workspace.getRegionW("bottom") - 20.0, 118.0); ui.text("Montage gameplay tracks", "action-timeline-label"); @@ -281,6 +967,35 @@ function mountPanels() { requireResult(combatEditor.timeline.setSnapSeconds(1.0 / 30.0), "Configure frame snapping"); } +function mountTimelinePanel() { + ui.beginBuild(); ui.beginWindow("Timeline", "root"); panelTimeline(); ui.end(); + ui.mountBuildAs("action.timeline"); ui.select("action.timeline"); + ui.setHostPos(combatEditor.workspace.getRegionX("bottom"), combatEditor.workspace.getRegionY("bottom"), 0.0, 0.0); + ui.setHostSize(combatEditor.workspace.getRegionW("bottom"), combatEditor.workspace.getRegionH("bottom")); + ui.setHostMovable(false); ui.setHostResizable(false); ui.setHostOverlay(false); +} + +function mountInspectorPanel() { + ui.beginBuild(); ui.beginWindow("Inspector", "root"); panelInspector(); ui.end(); + ui.mountBuildAs("action.inspector"); ui.select("action.inspector"); + ui.setHostPos(combatEditor.workspace.getRegionX("right"), combatEditor.workspace.getRegionY("right"), 0.0, 0.0); + ui.setHostSize(combatEditor.workspace.getRegionW("right"), combatEditor.workspace.getRegionH("right")); + ui.setHostMovable(false); ui.setHostResizable(false); ui.setHostOverlay(false); +} + +function tabUiSignature() { + local value = combatEditor.activeTab + ":" + combatEditor.closeArmed; + foreach (tab in combatEditor.tabs) value += ":" + tab.title + ":" + (tab.editor.isDirty() ? "1" : "0"); + return value; +} + +function refreshDocumentToolbar() { + local signature = tabUiSignature(); + if (signature == combatEditor.tabUiSignature) return; + combatEditor.tabUiSignature = signature; + mountTimelinePanel(); +} + function applyHistory(command) { local result = command == "undo" ? combatEditor.timeline.undo() : combatEditor.timeline.redo(); combatEditor.status = result.ok ? command + " applied" : command + ": " + result.status.summary; @@ -297,16 +1012,38 @@ function handleUiEvents() { local click = ui.consumeClick(); while (click != "") { local event = eventParts(click); local host = event[0]; local id = event[1]; - if (id == "play-pause") { + if (id == "save-document") { + saveActiveDocument(); + } else if (id == "open-document") { + showAssetPicker(); + } else if (id == "close-asset-picker") { + hideAssetPicker(); + } else if (id.find("pick-asset-") == 0) { + openCatalogAsset(id.slice(11).tointeger()); + } else if (id.find("close-tab-") == 0) { + closeTab(id.slice(10).tointeger()); + } else if (id.find("tab-") == 0) { + activateTab(id.slice(4).tointeger()); + } else if (id == "play-pause") { if (combatEditor.timeline.isPlaying()) { combatEditor.timeline.pause(); combatEditor.status = "Preview paused"; } else { combatEditor.timeline.play(); combatEditor.status = "Preview playing"; } - } else if (id == "restart") { - requireResult(combatEditor.timeline.seekSeconds(0.0), "Restart preview"); + } else if (id == "first-frame") { + requireResult(combatEditor.timeline.stop(), "Jump to first frame"); requireResult(combatEditor.timeline.jumpRuntimeSeconds(0.0), "Restart montage runtime"); - combatEditor.timeline.play(); combatEditor.status = "Preview restarted at 0.000 s"; + combatEditor.status = "Preview stopped at first frame"; + } else if (id == "previous-frame" || id == "next-frame") { + requireResult(combatEditor.timeline.stepFrames(id == "previous-frame" ? -1 : 1, 30.0), + "Step preview frame"); + combatEditor.status = id == "previous-frame" ? "Previous frame" : "Next frame"; + } else if (id == "stop") { + requireResult(combatEditor.timeline.stop(), "Stop preview"); + combatEditor.status = "Preview stopped at 0.000 s"; + } else if (id == "last-frame") { + requireResult(combatEditor.timeline.jumpToEnd(), "Jump to last frame"); + combatEditor.status = "Preview moved to final frame"; } else if (id == "jump-strike") { requireResult(combatEditor.timeline.jumpRuntimeSection(1), "Jump montage to strike section"); requireResult(combatEditor.timeline.seekSeconds(combatEditor.timeline.getDuration() * 0.30), @@ -315,14 +1052,139 @@ function handleUiEvents() { } else if (id == "blend-out") { requireResult(combatEditor.timeline.beginRuntimeBlendOut(0.20), "Blend out montage runtime"); combatEditor.status = "Runtime interruption · paired state exits + 0.20 s blend"; + } else if (id == "speed-slow" || id == "speed-normal" || id == "speed-fast") { + setPlaybackRate(id == "speed-slow" ? 0.5 : (id == "speed-fast" ? 1.5 : 1.0)); } else if (id == "undo" || id == "redo") { applyHistory(id); + } else if (id == "inspect-joint" || id == "inspect-action" || id == "inspect-track" || + id == "inspect-montage") { + combatEditor.inspectorMode = id == "inspect-action" ? "action" : + (id == "inspect-track" ? "track" : (id == "inspect-montage" ? "montage" : "joint")); + combatEditor.insertPanelOpen = false; + combatEditor.newSectionOpen = false; + combatEditor.newTrackOpen = false; + mountInspectorPanel(); + } else if (id == "new-action" || id == "cancel-insert") { + combatEditor.insertPanelOpen = id == "new-action"; + combatEditor.newSectionOpen = false; + mountInspectorPanel(); + } else if (id == "new-section" || id == "cancel-new-section") { + combatEditor.newSectionOpen = id == "new-section"; + combatEditor.insertPanelOpen = false; + mountInspectorPanel(); + } else if (id == "add-section") { + ui.select("action.inspector"); combatEditor.nextSectionId += 1; + local result = combatEditor.timeline.addAnimationSection( + "editor-section:section-" + combatEditor.nextSectionId, ui.getValueText("new-section-uri"), + ui.getValue("new-section-start"), ui.getValue("new-section-end"), ui.getValue("new-section-blend")); + combatEditor.status = result.ok ? "Animation Section created" : result.status.summary; + if (result.ok) { combatEditor.newSectionOpen = false; mountInspectorPanel(); } + } else if (id == "new-track" || id == "cancel-new-track") { + combatEditor.newTrackOpen = id == "new-track"; + mountInspectorPanel(); + } else if (id == "add-track") { + ui.select("action.inspector"); + combatEditor.nextTrackId += 1; + local trackId = "editor-track:custom-" + combatEditor.nextTrackId; + local result = combatEditor.timeline.addTrack(trackId, ui.getValueText("new-track-label"), + trackKindAt(combatEditor.newTrackKind)); + combatEditor.status = result.ok ? "Track created" : result.status.summary; + if (result.ok) { + combatEditor.selectedTrack = combatEditor.timeline.getTrackCount() - 1; + combatEditor.newTrackOpen = false; mountInspectorPanel(); + } + } else if (id == "copy-track" || id == "paste-track" || id == "delete-track") { + local result = id == "paste-track" ? combatEditor.timeline.pasteTrack() : + (id == "copy-track" ? combatEditor.timeline.copyTrack( + combatEditor.timeline.getTrackId(combatEditor.selectedTrack)) : + combatEditor.timeline.removeTrack(combatEditor.timeline.getTrackId(combatEditor.selectedTrack))); + combatEditor.status = result.ok ? (id == "copy-track" ? "Track copied" : + (id == "paste-track" ? "Track pasted" : "Track deleted")) : result.status.summary; + if (result.ok && id == "paste-track") combatEditor.selectedTrack = combatEditor.timeline.getTrackCount() - 1; + if (combatEditor.selectedTrack >= combatEditor.timeline.getTrackCount()) + combatEditor.selectedTrack = combatEditor.timeline.getTrackCount() - 1; + } else if (id == "track-undo" || id == "track-redo") { + applyHistory(id == "track-undo" ? "undo" : "redo"); + } else if (id == "montage-undo" || id == "montage-redo") { + applyHistory(id == "montage-undo" ? "undo" : "redo"); + } else if (id == "apply-montage-mask") { + ui.select("action.inspector"); + local mask = anim.newBoneMask(combatEditor.skeleton); + mask.setAll(0.0); + local bone = combatEditor.clipEditor.getSelectedBone(); + local applied = ui.getChecked("montage-mask-children") ? + mask.setBoneAndChildren(bone, ui.getValue("montage-mask-weight")) : + mask.setBoneWeightByName(bone, ui.getValue("montage-mask-weight")); + if (!applied) combatEditor.status = "Bone mask joint was not found · " + bone; + else { + local result = combatEditor.timeline.setRuntimeBoneMask(mask); + combatEditor.status = result.ok ? "Layer mask applied from joint · " + bone + : result.status.summary; + } + } else if (id == "clear-montage-mask") { + local result = combatEditor.timeline.clearRuntimeBoneMask(); + combatEditor.status = result.ok ? "Layer bone mask cleared" : result.status.summary; + } else if (id == "add-section-split") { + local splitTime = combatEditor.timeline.getPreviewTime(); + local result = combatEditor.timeline.addSectionSplit(splitTime); + if (result.ok) { + combatEditor.selectedSplit = sectionSplitIndexAt(splitTime); + mountInspectorPanel(); + } + combatEditor.status = result.ok ? "Physical section split added" : result.status.summary; + } else if (id == "delete-section-split") { + local result = combatEditor.timeline.removeSectionSplit(combatEditor.selectedSplit); + if (result.ok) { + combatEditor.selectedSplit = combatEditor.timeline.getSectionSplitCount() > 0 ? + clampf(combatEditor.selectedSplit, 0, combatEditor.timeline.getSectionSplitCount() - 1).tointeger() : 0; + mountInspectorPanel(); + } + combatEditor.status = result.ok ? "Physical section split deleted" : result.status.summary; + } else if (id == "align-selection-start" || id == "align-selection-end") { + local result = combatEditor.timeline.handleTimelineShortcut( + id == "align-selection-start" ? "Shift+[" : "Shift+]"); + combatEditor.status = result.ok ? (id == "align-selection-start" ? + "Selected blocks aligned to earliest start" : "Selected blocks aligned to latest end") : + result.status.summary; + } else if (id == "delete-action" && combatEditor.selectedActionId != "") { + local result = combatEditor.timeline.handleTimelineShortcut("Delete"); + combatEditor.status = result.ok ? "Selected Action Blocks deleted" : result.status.summary; + if (result.ok) combatEditor.selectedActionId = ""; + } else if (id == "copy-action") { + local result = combatEditor.timeline.handleTimelineShortcut("Ctrl+C"); + combatEditor.status = result.ok ? "Action Block copied" : result.status.summary; + } else if (id == "paste-action") { + local result = combatEditor.timeline.handleTimelineShortcut("Ctrl+V"); + combatEditor.status = result.ok ? "Action Block pasted at playhead" : result.status.summary; + } else if (id == "insert-notify" || id == "insert-state") { + ui.select("action.inspector"); + local state = id == "insert-state"; + local typeIndex = state ? combatEditor.insertStateType : combatEditor.insertNotifyType; + local trackId = combatEditor.timeline.getTrackId(combatEditor.insertTrack); + local type = combatEditor.timeline.getInsertableType(state, typeIndex); + local payload = ui.getValueText(state ? "insert-state-payload" : "insert-notify-payload"); + local result = state ? combatEditor.timeline.addStateAtCursor( + trackId, type, ui.getValue("insert-state-duration"), payload) : + combatEditor.timeline.addNotifyAtCursor(trackId, type, payload); + combatEditor.status = result.ok ? (state ? "Notify State inserted" : "Notify inserted") + : result.status.summary; + } else if (id == "action-undo" || id == "action-redo") { + applyHistory(id == "action-undo" ? "undo" : "redo"); + } else if ((id == "vfx-fit-block" || id == "vfx-fit-clip" || id == "vfx-use-natural") && + combatEditor.selectedActionId != "") { + local result = id == "vfx-fit-block" ? combatEditor.timeline.fitBlockToClip( + combatEditor.selectedActionId) : (id == "vfx-fit-clip" ? + combatEditor.timeline.fitClipToBlock(combatEditor.selectedActionId) : + combatEditor.timeline.fitClipToNaturalDuration(combatEditor.selectedActionId)); + combatEditor.status = result.ok ? (id == "vfx-fit-block" ? "VFX block fitted to clip at 1.0x" : + (id == "vfx-fit-clip" ? "VFX clip fitted to block" : "VFX clip end read from asset")) : + result.status.summary; + if (result.ok) mountInspectorPanel(); } else if (id == "set-key" || id == "delete-key" || id == "clip-undo" || id == "clip-redo") { local result = id == "set-key" ? combatEditor.clipEditor.keySelectedBone() : id == "delete-key" ? combatEditor.clipEditor.deleteSelectedKey() : id == "clip-undo" ? combatEditor.clipEditor.undo() : combatEditor.clipEditor.redo(); - if (result.ok) requireResult(combatEditor.clipEditor.writeRuntimeClip(combatEditor.clip, combatEditor.skeleton), - "Rebuild edited KayKit clip"); + if (result.ok) refreshEditedMontageClip(); combatEditor.status = result.ok ? "Animation key edit committed" : result.status.summary; } else if (host == "action.assets" && id.find("bone-") == 0) { local index = id.slice(5).tointeger(); @@ -335,20 +1197,156 @@ function handleUiEvents() { local changed = ui.consumeChange(); while (changed != "") { local event = eventParts(changed); local host = event[0]; local id = event[1]; - if (host == "action.inspector") { + if (host == "action.asset-picker" && id == "asset-search") { + ui.select("action.asset-picker"); combatEditor.assetSearch = ui.getValueText("asset-search"); + local refreshed = combatEditor.assetCatalog.refresh(combatEditor.assetSearch); + combatEditor.status = refreshed.ok ? "AssetDB query · " + refreshed.value + " Montage assets" + : "AssetDB query failed · " + refreshed.status.summary; + mountAssetPicker(); + } else if (host == "action.inspector") { ui.select("action.inspector"); local result = { ok=true, status={summary=""} }; - if (id.find("position-") == 0) result = combatEditor.clipEditor.setSelectedPosition( + if (id == "section-split-selector") { + combatEditor.selectedSplit = ui.getValue(id).tointeger(); + } else if (id == "section-split-time" && combatEditor.timeline.getSectionSplitCount() > 0) { + local splitTime = ui.getValue(id); + result = combatEditor.timeline.setSectionSplit(combatEditor.selectedSplit, splitTime); + if (result.ok) combatEditor.selectedSplit = sectionSplitIndexAt(splitTime); + combatEditor.status = result.ok ? "Physical section split moved" : result.status.summary; + } else if (id == "montage-runtime-weight") { + result = combatEditor.timeline.setRuntimeLayerWeight(ui.getValue(id)); + combatEditor.status = result.ok ? "Runtime layer weight updated" : result.status.summary; + } else if (id == "montage-runtime-additive") { + result = combatEditor.timeline.setRuntimeLayerAdditive(ui.getChecked(id)); + combatEditor.status = result.ok ? "Runtime layer mode updated" : result.status.summary; + } else if (id.find("montage-") == 0 && id != "montage-tools" && + id.find("montage-mask-") != 0 && id.find("montage-runtime-") != 0) { + result = commitMontageSettings(); + combatEditor.status = result.ok ? "Montage settings committed" : result.status.summary; + } else if (id == "insert-track") combatEditor.insertTrack = ui.getValue("insert-track").tointeger(); + else if (id == "track-selector") + combatEditor.selectedTrack = ui.getValue("track-selector").tointeger(); + else if (id == "new-track-kind") + combatEditor.newTrackKind = ui.getValue("new-track-kind").tointeger(); + else if (id == "track-label" && combatEditor.timeline.getTrackCount() > 0) { + result = combatEditor.timeline.renameTrack(combatEditor.timeline.getTrackId(combatEditor.selectedTrack), + ui.getValueText("track-label")); + combatEditor.status = result.ok ? "Track renamed" : result.status.summary; + } else if ((id == "track-muted" || id == "track-locked") && combatEditor.timeline.getTrackCount() > 0) { + local trackId = combatEditor.timeline.getTrackId(combatEditor.selectedTrack); + result = id == "track-muted" ? combatEditor.timeline.setTrackMuted(trackId, ui.getChecked(id)) : + combatEditor.timeline.setTrackLocked(trackId, ui.getChecked(id)); + combatEditor.status = result.ok ? "Track flags committed" : result.status.summary; + } + else if (id == "insert-notify-type") { + combatEditor.insertNotifyType = ui.getValue("insert-notify-type").tointeger(); + ui.setValueText("insert-notify-payload", defaultPayloadForType( + combatEditor.timeline.getInsertableType(false, combatEditor.insertNotifyType))); + } else if (id == "insert-state-type") { + combatEditor.insertStateType = ui.getValue("insert-state-type").tointeger(); + ui.setValueText("insert-state-payload", defaultPayloadForType( + combatEditor.timeline.getInsertableType(true, combatEditor.insertStateType))); + } else if (id == "payload-uri" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadText( + combatEditor.selectedActionId, "uri", ui.getValueText(id)); + } else if (id == "audio-random-uris" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadTextList( + combatEditor.selectedActionId, "randomUris", ui.getValueText(id)); + } else if ((id == "audio-volume" || id == "audio-pitch" || id == "audio-random-pitch" || + id == "audio-spatial-blend" || id == "audio-min-distance" || id == "audio-max-distance" || + id == "audio-fade-duration") && + combatEditor.selectedActionId != "") { + local field = id == "audio-volume" ? "volume" : (id == "audio-pitch" ? "pitch" : + (id == "audio-random-pitch" ? "randomPitchOffset" : + (id == "audio-spatial-blend" ? "spatialBlend" : + (id == "audio-min-distance" ? "minDistance" : + (id == "audio-max-distance" ? "maxDistance" : "fadeOutDuration"))))); + result = combatEditor.timeline.setItemPayloadNumber( + combatEditor.selectedActionId, field, ui.getValue(id)); + } else if ((id == "audio-looping" || id == "audio-fade-out") && + combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadBool( + combatEditor.selectedActionId, id == "audio-looping" ? "looping" : "fadeOutOnExit", + ui.getChecked(id)); + } else if (id == "vfx-stop" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadText(combatEditor.selectedActionId, + "stopBehavior", stopBehaviorAt(ui.getValue(id).tointeger())); + } else if (id == "vfx-sync-rate" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadBool( + combatEditor.selectedActionId, "playbackRateSynced", ui.getChecked(id)); + } else if ((id == "vfx-clip-start" || id == "vfx-clip-end" || id == "vfx-lifetime") && + combatEditor.selectedActionId != "") { + local field = id == "vfx-clip-start" ? "clipStartTime" : + (id == "vfx-clip-end" ? "clipEndTime" : "lifetimeSeconds"); + result = combatEditor.timeline.setItemPayloadNumber( + combatEditor.selectedActionId, field, ui.getValue(id)); + } else if (id == "prefab-lifecycle" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.patchItemPayload(combatEditor.selectedActionId, + format("{\"lifecycle\":\"%s\",\"customDurationSeconds\":%.6f}", + lifecycleAt(ui.getValue(id).tointeger()), ui.getValue("prefab-duration"))); + } else if (id == "prefab-duration" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadNumber( + combatEditor.selectedActionId, "customDurationSeconds", ui.getValue(id)); + } else if (id == "spatial-attachment" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadText(combatEditor.selectedActionId, + "attachment", attachmentAt(ui.getValue(id).tointeger())); + } else if (id == "spatial-target" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadText(combatEditor.selectedActionId, + "spatialTarget", ui.getValue(id).tointeger() == 1 ? "target" : "source"); + } else if (id == "spatial-target-index" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadInteger( + combatEditor.selectedActionId, "targetIndex", ui.getValue(id).tointeger()); + } else if (id == "spatial-bone" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemPayloadText( + combatEditor.selectedActionId, "bone", ui.getValueText(id)); + } else if (id.find("spatial-position-") == 0) { + result = commitPayloadVector("positionOffset", "spatial-position"); + } else if (id.find("spatial-rotation-") == 0) { + result = commitPayloadVector("rotationOffsetDegrees", "spatial-rotation"); + } else if (id.find("spatial-scale-") == 0) { + result = commitPayloadVector("scale", "spatial-scale"); + } else if ((id == "action-start" || id == "action-end") && combatEditor.selectedActionId != "") { + local start = ui.getValue("action-start"); + local finish = combatEditor.selectedActionState ? ui.getValue("action-end") : start; + result = combatEditor.timeline.setItemTiming(combatEditor.selectedActionId, start, finish); + combatEditor.status = result.ok ? "Action Block timing committed" : result.status.summary; + } else if (id == "action-enabled" && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.setItemEnabled(combatEditor.selectedActionId, + ui.getChecked("action-enabled")); + combatEditor.status = result.ok ? "Action Block enabled state committed" : result.status.summary; + } else if ((id == "action-type" || id == "action-payload") && combatEditor.selectedActionId != "") { + result = combatEditor.timeline.editItemDetails(combatEditor.selectedActionId, + ui.getValueText("action-type"), ui.getValueText("action-payload")); + combatEditor.status = result.ok ? "Action Block details committed" : result.status.summary; + } else if ((id == "section-uri" || id == "section-blend") && + animationSectionIndex(combatEditor.selectedActionId) >= 0) { + result = combatEditor.timeline.editAnimationSection(combatEditor.selectedActionId, + ui.getValue("action-start"), ui.getValue("action-end"), ui.getValue("section-blend"), + ui.getValueText("section-uri")); + combatEditor.status = result.ok ? "Animation Section committed" : result.status.summary; + } else if ((id == "section-source-start" || id == "section-source-end" || id == "section-curve") && + animationSectionIndex(combatEditor.selectedActionId) >= 0) { + result = combatEditor.timeline.editAnimationSectionSource(combatEditor.selectedActionId, + ui.getValue("section-source-start"), ui.getValue("section-source-end"), + blendCurveAt(ui.getValue("section-curve").tointeger())); + combatEditor.status = result.ok ? "Section source trim committed" : result.status.summary; + } else if (id.find("position-") == 0) result = combatEditor.clipEditor.setSelectedPosition( ui.getValue("position-x"), ui.getValue("position-y"), ui.getValue("position-z")); else if (id.find("rotation-") == 0) result = combatEditor.clipEditor.setSelectedRotation( ui.getValue("rotation-x"), ui.getValue("rotation-y"), ui.getValue("rotation-z")); else if (id.find("scale-") == 0) result = combatEditor.clipEditor.setSelectedScale( ui.getValue("scale-x"), ui.getValue("scale-y"), ui.getValue("scale-z")); else if (id == "key-time") result = combatEditor.clipEditor.moveSelectedKey(ui.getValue("key-time")); - if (result.ok) requireResult(combatEditor.clipEditor.writeRuntimeClip(combatEditor.clip, combatEditor.skeleton), - "Rebuild edited KayKit clip"); - combatEditor.status = result.ok ? "Joint transform keyed · revision " + combatEditor.clipEditor.getRevision() - : result.status.summary; + local jointChange = id.find("position-") == 0 || id.find("rotation-") == 0 || + id.find("scale-") == 0 || id == "key-time"; + if (jointChange && result.ok) refreshEditedMontageClip(); + if (jointChange) + combatEditor.status = result.ok ? "Joint transform keyed · revision " + combatEditor.clipEditor.getRevision() + : result.status.summary; + else if (!result.ok) combatEditor.status = result.status.summary; + else if (id.find("payload-") == 0 || id.find("audio-") == 0 || id.find("vfx-") == 0 || + id.find("prefab-") == 0 || id.find("spatial-") == 0) + combatEditor.status = "Typed Action Block properties committed"; } changed = ui.consumeChange(); } @@ -377,7 +1375,18 @@ function updateTimelinePointer() { requireResult(combatEditor.timeline.zoomTimeline(pow(1.18, wheel), anchor), "Zoom timeline"); } if (down && !combatEditor.mouseDown) { - combatEditor.timeline.pointerDown(x, y, false); + local additive = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl") || + keyboard.isDown("lshift") || keyboard.isDown("rshift") || keyboard.isDown("shift"); + combatEditor.timeline.pointerDown(x, y, additive); + local selectedIndex = selectedActionIndex(); + if (selectedIndex >= 0) { + combatEditor.selectedActionId = combatEditor.timeline.getItemId(selectedIndex); + combatEditor.selectedActionState = combatEditor.timeline.getItemState(selectedIndex); + if (combatEditor.inspectorMode != "action") { + combatEditor.inspectorMode = "action"; + mountInspectorPanel(); + } + } if (!combatEditor.timeline.isDragging()) { requireResult(combatEditor.timeline.seekX(x), "Seek timeline"); combatEditor.status = "Playhead positioned from timeline"; @@ -390,7 +1399,7 @@ function updateTimelinePointer() { combatEditor.mouseDown = down; } -function updatePreviewCamera() { +function updatePreviewCamera(dt) { ui.select("action.preview"); local hovered = ui.viewportHovered("combat-preview"); local x = ui.viewportMouseX("combat-preview"); local y = ui.viewportMouseY("combat-preview"); @@ -406,11 +1415,10 @@ function updatePreviewCamera() { combatEditor.previewLastX = x; combatEditor.previewLastY = y; combatEditor.previewOrbiting = true; } else combatEditor.previewOrbiting = false; - local planar = combatEditor.previewDistance * cos(combatEditor.previewPitch); - combatEditor.camera.setEye(0.1 + planar * sin(combatEditor.previewYaw), - 0.85 + combatEditor.previewDistance * sin(combatEditor.previewPitch), - planar * cos(combatEditor.previewYaw)); - combatEditor.camera.setTarget(0.1, 0.85, 0.0); + combatEditor.cameraController.setRadius(combatEditor.previewDistance); + combatEditor.cameraController.setAzimuth(combatEditor.previewYaw * 57.2957795); + combatEditor.cameraController.setElevation(combatEditor.previewPitch * 57.2957795); + combatEditor.cameraController.update(dt); } function updateKeyboardShortcuts() { @@ -423,25 +1431,210 @@ function updateKeyboardShortcuts() { local control = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl"); local undo = control && (keyboard.isDown("z") || keyboard.isDown("Z")); local redo = control && (keyboard.isDown("y") || keyboard.isDown("Y")); + local save = control && (keyboard.isDown("s") || keyboard.isDown("S")); if (undo && !combatEditor.undoWas) applyHistory("undo"); if (redo && !combatEditor.redoWas) applyHistory("redo"); - combatEditor.undoWas = undo; combatEditor.redoWas = redo; + if (save && !combatEditor.saveWas) { + saveActiveDocument(); + } + local attack = keyboard.isDown("j") || keyboard.isDown("J"); + if (attack && !combatEditor.attackWas) { + if (combatEditor.comboWindowOpen && combatEditor.comboInputTag != "") + activateTriggeredAbility(combatEditor.comboInputTag); + else + combatEditor.status = "Attack input ignored · outside authored combo window"; + } + combatEditor.attackWas = attack; + local slow = keyboard.isDown("q") || keyboard.isDown("Q"); + local normal = keyboard.isDown("e") || keyboard.isDown("E"); + local fast = keyboard.isDown("r") || keyboard.isDown("R"); + if (slow && !combatEditor.slowWas) setPlaybackRate(0.5); + if (normal && !combatEditor.normalWas) setPlaybackRate(1.0); + if (fast && !combatEditor.fastWas) setPlaybackRate(1.5); + combatEditor.slowWas = slow; combatEditor.normalWas = normal; combatEditor.fastWas = fast; + combatEditor.undoWas = undo; combatEditor.redoWas = redo; combatEditor.saveWas = save; } -function updatePose() { - combatEditor.player.setTime(combatEditor.clipEditor.getPlayhead()); - local pose = combatEditor.player.getPose(); +function updatePose(dt) { + combatEditor.basePlayer.update(dt); + local pose = combatEditor.timeline.getRuntimePoseOverBase(combatEditor.basePlayer.getPose()); + if (pose == null) { + combatEditor.player.setTime(combatEditor.clipEditor.getPlayhead()); + pose = combatEditor.player.getPose(); + } pose.computeWorld(combatEditor.skeleton); foreach (binding in combatEditor.skins) binding.skin.applyToMesh(gfx, binding.part.getMesh(), pose); + combatEditor.enemyPlayer.update(dt); + local enemyPose = combatEditor.enemyPlayer.getPose(); enemyPose.computeWorld(combatEditor.enemySkeleton); + foreach (binding in combatEditor.enemySkins) + binding.skin.applyToMesh(gfx, binding.part.getMesh(), enemyPose); } function updateLabels() { ui.select("action.preview"); - ui.setText("preview-status", format("%s %.3f / %.3f s", + ui.setText("preview-status", format("%s %.3f / %.3f s · Montage %s L%d/S%d %.3f / %.3f s", combatEditor.timeline.isPlaying() ? "PLAYING" : "PAUSED", - combatEditor.timeline.getPreviewTime(), combatEditor.timeline.getDuration())); + combatEditor.timeline.getPreviewTime(), combatEditor.timeline.getDuration(), + combatEditor.timeline.getRuntimeState(), combatEditor.timeline.getRuntimeLayer(), + combatEditor.timeline.getRuntimeSlot(), combatEditor.timeline.getRuntimeElapsedSeconds(), + combatEditor.timeline.getRuntimeDurationSeconds())); + local runtimeSection = combatEditor.timeline.getRuntimePhysicalSection(); + local runtimeProgress = runtimeSection >= 0 ? combatEditor.timeline.getRuntimeSectionProgress(runtimeSection) : 0.0; + ui.setText("combat-status", format("Player %.0f HP · Target %.0f HP · %s · Ability %s (%.2fs) · Combo %s x%d · Sec %d %.0f%% · W %.2f · %.1fx", + combatEditor.playerHealth, combatEditor.enemyHealth, combatEditor.enemyReaction, + combatEditor.abilityPhase, combatEditor.abilityCooldown, + combatEditor.comboWindowOpen ? "OPEN [J]" : "closed", combatEditor.comboCount, + runtimeSection, runtimeProgress * 100.0, combatEditor.timeline.getRuntimeWeight(), + combatEditor.playbackRate)); ui.select("action.inspector"); ui.setText("action-uri", "Clip: Melee_1H_Attack_Chop"); ui.setText("revision", "Revision " + combatEditor.clipEditor.getRevision()); + if (combatEditor.inspectorMode == "action") { + local selected = selectedActionIndex(); + local selectionCount = selectedActionCount(); + local selectionRange = selectedActionRange(); + ui.setText("action-selection-summary", selectionCount == 0 ? "Selection: 0 blocks" : + format("Selection: %d blocks · %.3f–%.3f s", selectionCount, selectionRange[0], selectionRange[1])); + ui.setEnabled("align-selection-start", selectionCount > 1); + ui.setEnabled("align-selection-end", selectionCount > 1); + if (selected >= 0) { + combatEditor.selectedActionId = combatEditor.timeline.getItemId(selected); + combatEditor.selectedActionState = combatEditor.timeline.getItemState(selected); + local selectedType = combatEditor.timeline.getItemType(selected); + local sectionIndex = animationSectionIndex(combatEditor.selectedActionId); + local isSection = sectionIndex >= 0; + local single = selectionCount == 1; + setTypedPayloadVisibility(selectedType, single && !isSection); + ui.setText("action-block-id", combatEditor.selectedActionId); + local blockLabel = combatEditor.timeline.getItemLabel(selected); + local blockDetail = combatEditor.timeline.getItemDetail(selected); + ui.setText("action-block-kind", blockLabel + + (blockDetail == "" ? "" : " · " + blockDetail)); + ui.setValueText("action-type", selectedType); + ui.setValueText("action-payload", combatEditor.timeline.getItemPayloadJson(combatEditor.selectedActionId)); + ui.setChecked("action-enabled", combatEditor.timeline.getItemEnabled(combatEditor.selectedActionId)); + ui.setValue("action-start", combatEditor.timeline.getItemStart(combatEditor.selectedActionId)); + ui.setValue("action-end", combatEditor.timeline.getItemEnd(combatEditor.selectedActionId)); + ui.setEnabled("action-start", single); + ui.setEnabled("action-end", single && (combatEditor.selectedActionState || isSection)); + ui.setEnabled("action-enabled", single && !isSection); + ui.setEnabled("action-type", single && !isSection); + ui.setEnabled("action-payload", single && !isSection); + ui.setVisible("action-type-label", !isSection); ui.setVisible("action-type", !isSection); + ui.setVisible("action-payload-label", !isSection); ui.setVisible("action-payload", !isSection); + ui.setVisible("action-enabled", !isSection); + foreach (field in ["section-fields-title", "section-uri", "section-blend", "section-source-start", + "section-source-end", "section-curve"]) ui.setVisible(field, isSection); + if (isSection) { + ui.setValueText("section-uri", combatEditor.timeline.getAnimationSectionUri(sectionIndex)); + ui.setValue("section-blend", combatEditor.timeline.getAnimationSectionBlendIn(sectionIndex)); + ui.setValue("section-source-start", combatEditor.timeline.getAnimationSectionSourceStart(sectionIndex)); + ui.setValue("section-source-end", combatEditor.timeline.getAnimationSectionSourceEnd(sectionIndex)); + ui.setValue("section-curve", blendCurveIndex( + combatEditor.timeline.getAnimationSectionBlendCurve(sectionIndex)).tofloat()); + } else if (single && isSpatialType(selectedType)) { + local itemId = combatEditor.selectedActionId; + ui.setValueText("payload-uri", combatEditor.timeline.getItemPayloadText(itemId, "uri", "")); + ui.setValue("spatial-attachment", attachmentIndex( + combatEditor.timeline.getItemPayloadText(itemId, "attachment", "follow_target")).tofloat()); + ui.setValue("spatial-target", combatEditor.timeline.getItemPayloadText( + itemId, "spatialTarget", "source") == "target" ? 1.0 : 0.0); + ui.setValue("spatial-target-index", combatEditor.timeline.getItemPayloadNumber( + itemId, "targetIndex", 0.0)); + ui.setValueText("spatial-bone", combatEditor.timeline.getItemPayloadText(itemId, "bone", "")); + foreach (axis, component in { x=0, y=1, z=2 }) { + ui.setValue("spatial-position-" + axis, combatEditor.timeline.getItemPayloadVector( + itemId, "positionOffset", component, 0.0)); + ui.setValue("spatial-rotation-" + axis, combatEditor.timeline.getItemPayloadVector( + itemId, "rotationOffsetDegrees", component, 0.0)); + ui.setValue("spatial-scale-" + axis, combatEditor.timeline.getItemPayloadVector( + itemId, "scale", component, 1.0)); + } + if (isAudioType(selectedType)) { + ui.setValueText("audio-random-uris", combatEditor.timeline.getItemPayloadTextList( + itemId, "randomUris")); + ui.setValue("audio-volume", combatEditor.timeline.getItemPayloadNumber(itemId, "volume", 1.0)); + ui.setValue("audio-pitch", combatEditor.timeline.getItemPayloadNumber(itemId, "pitch", 1.0)); + ui.setValue("audio-random-pitch", combatEditor.timeline.getItemPayloadNumber( + itemId, "randomPitchOffset", 0.0)); + ui.setValue("audio-spatial-blend", combatEditor.timeline.getItemPayloadNumber( + itemId, "spatialBlend", 1.0)); + ui.setValue("audio-min-distance", combatEditor.timeline.getItemPayloadNumber( + itemId, "minDistance", 1.0)); + ui.setValue("audio-max-distance", combatEditor.timeline.getItemPayloadNumber( + itemId, "maxDistance", 35.0)); + ui.setChecked("audio-looping", combatEditor.timeline.getItemPayloadBool(itemId, "looping", false)); + ui.setChecked("audio-fade-out", combatEditor.timeline.getItemPayloadBool( + itemId, "fadeOutOnExit", true)); + ui.setValue("audio-fade-duration", combatEditor.timeline.getItemPayloadNumber( + itemId, "fadeOutDuration", 0.1)); + } else if (isVfxType(selectedType)) { + ui.setValue("vfx-stop", stopBehaviorIndex(combatEditor.timeline.getItemPayloadText( + itemId, "stopBehavior", "stop_emitting")).tofloat()); + ui.setChecked("vfx-sync-rate", combatEditor.timeline.getItemPayloadBool( + itemId, "playbackRateSynced", true)); + ui.setValue("vfx-clip-start", combatEditor.timeline.getItemPayloadNumber( + itemId, "clipStartTime", 0.0)); + ui.setValue("vfx-clip-end", combatEditor.timeline.getItemPayloadNumber( + itemId, "clipEndTime", 0.5)); + ui.setValue("vfx-lifetime", combatEditor.timeline.getItemPayloadNumber( + itemId, "lifetimeSeconds", 1.0)); + } else if (isPrefabType(selectedType)) { + ui.setValue("prefab-lifecycle", lifecycleIndex(combatEditor.timeline.getItemPayloadText( + itemId, "lifecycle", "recycle_on_block_exit")).tofloat()); + ui.setValue("prefab-duration", combatEditor.timeline.getItemPayloadNumber( + itemId, "customDurationSeconds", 1.0)); + } + } + ui.setEnabled("delete-action", true); + } else { + combatEditor.selectedActionId = ""; + setTypedPayloadVisibility("", false); + ui.setText("action-block-id", "No Action Block selected"); + ui.setText("action-block-kind", "Select a block in the montage timeline"); + ui.setEnabled("action-start", false); ui.setEnabled("action-end", false); + ui.setEnabled("action-enabled", false); ui.setEnabled("action-type", false); + ui.setEnabled("action-payload", false); ui.setEnabled("delete-action", false); + foreach (field in ["section-fields-title", "section-uri", "section-blend", "section-source-start", + "section-source-end", "section-curve"]) ui.setVisible(field, false); + } + ui.setEnabled("action-undo", combatEditor.timeline.canUndo()); + ui.setEnabled("action-redo", combatEditor.timeline.canRedo()); + } else if (combatEditor.inspectorMode == "track" && !combatEditor.newTrackOpen && + combatEditor.timeline.getTrackCount() > 0) { + combatEditor.selectedTrack = clampf(combatEditor.selectedTrack, 0, + combatEditor.timeline.getTrackCount() - 1).tointeger(); + ui.setText("track-id", combatEditor.timeline.getTrackId(combatEditor.selectedTrack)); + ui.setText("track-kind", "Kind: " + combatEditor.timeline.getTrackKind(combatEditor.selectedTrack)); + ui.setValueText("track-label", combatEditor.timeline.getTrackLabel(combatEditor.selectedTrack)); + ui.setChecked("track-muted", combatEditor.timeline.getTrackMuted(combatEditor.selectedTrack)); + ui.setChecked("track-locked", combatEditor.timeline.getTrackLocked(combatEditor.selectedTrack)); + ui.setEnabled("delete-track", combatEditor.timeline.getTrackCount() > 1); + ui.setEnabled("track-undo", combatEditor.timeline.canUndo()); + ui.setEnabled("track-redo", combatEditor.timeline.canRedo()); + } else if (combatEditor.inspectorMode == "montage") { + ui.setValue("montage-rate", combatEditor.timeline.getMontageBasePlayRate()); + ui.setChecked("montage-looping", combatEditor.timeline.getMontageLooping()); + ui.setChecked("montage-foot-ik", combatEditor.timeline.getMontageFootIk()); + ui.setValue("montage-layer", combatEditor.timeline.getMontageAnimationLayer().tofloat()); + ui.setValue("montage-blend-in", combatEditor.timeline.getMontageBlendIn()); + ui.setValue("montage-blend-out", combatEditor.timeline.getMontageBlendOut()); + ui.setValue("montage-blend-offset", combatEditor.timeline.getMontageBlendOutOffset()); + ui.setChecked("montage-root-horizontal", combatEditor.timeline.getMontageRootMotionHorizontal()); + ui.setChecked("montage-root-vertical", combatEditor.timeline.getMontageRootMotionVertical()); + ui.setChecked("montage-root-rotation", combatEditor.timeline.getMontageRootMotionRotation()); + ui.setValue("montage-runtime-weight", combatEditor.timeline.getRuntimeLayerWeight()); + ui.setChecked("montage-runtime-additive", combatEditor.timeline.getRuntimeLayerAdditive()); + local splitCount = combatEditor.timeline.getSectionSplitCount(); + combatEditor.selectedSplit = splitCount > 0 ? clampf(combatEditor.selectedSplit, 0, splitCount - 1).tointeger() : 0; + ui.setValue("section-split-selector", combatEditor.selectedSplit.tofloat()); + ui.setValue("section-split-time", splitCount > 0 ? + combatEditor.timeline.getSectionSplitTime(combatEditor.selectedSplit) : 0.0); + ui.setEnabled("section-split-selector", splitCount > 0); + ui.setEnabled("section-split-time", splitCount > 0); + ui.setEnabled("delete-section-split", splitCount > 0); + ui.setEnabled("montage-undo", combatEditor.timeline.canUndo()); + ui.setEnabled("montage-redo", combatEditor.timeline.canRedo()); + } ui.setText("selected-item", "Joint: " + combatEditor.clipEditor.getSelectedBone()); ui.setText("last-event", combatEditor.lastEvent); ui.setValue("position-x", combatEditor.clipEditor.getSelectedPositionX()); ui.setValue("position-y", combatEditor.clipEditor.getSelectedPositionY()); ui.setValue("position-z", combatEditor.clipEditor.getSelectedPositionZ()); @@ -477,6 +1670,7 @@ function drawTimeline() { } for (local i = 0; i < combatEditor.timeline.getItemCount(); ++i) { local selected = combatEditor.timeline.getItemSelected(i); + local enabled = combatEditor.timeline.getItemEnabled(combatEditor.timeline.getItemId(i)); local isState = combatEditor.timeline.getItemState(i); local r = selected ? 0.98 : (isState ? 0.25 : 0.86); local g = selected ? 0.67 : (isState ? 0.58 : 0.38); @@ -484,7 +1678,58 @@ function drawTimeline() { local x0 = combatEditor.timeline.getItemMinX(i); local x1 = combatEditor.timeline.getItemMaxX(i); local y0 = combatEditor.timeline.getItemMinY(i) + 5.0; local y1 = combatEditor.timeline.getItemMaxY(i) - 5.0; - gfx.drawSolidRect(x0, y0, x1 - x0, y1 - y0, r, g, b, 0.95); + gfx.drawSolidRect(x0, y0, x1 - x0, y1 - y0, r, g, b, enabled ? 0.95 : 0.28); + local visual = combatEditor.timeline.getItemVisual(i); + local iconX = x0 + 4.0; local iconY = y0 + 4.0; + local iconW = x1 - x0 - 8.0 < 13.0 ? x1 - x0 - 8.0 : 13.0; + local iconH = y1 - y0 - 8.0; + if (iconW >= 5.0 && iconH >= 6.0) { + if (visual == "audio") { + for (local bar = 0; bar < 4; ++bar) { + local h = bar % 2 == 0 ? iconH * 0.55 : iconH; + gfx.drawSolidRect(iconX + bar * 3.0, iconY + (iconH - h) * 0.5, + 1.5, h, 0.92, 0.98, 1.0, enabled ? 0.9 : 0.35); + } + } else if (visual == "vfx") { + gfx.drawSolidRect(iconX + iconW * 0.45, iconY, 1.5, iconH, + 1.0, 0.92, 0.45, enabled ? 0.95 : 0.35); + gfx.drawSolidRect(iconX, iconY + iconH * 0.45, iconW, 1.5, + 1.0, 0.92, 0.45, enabled ? 0.95 : 0.35); + } else if (visual == "camera") { + gfx.drawSolidRect(iconX, iconY + 2.0, iconW - 3.0, iconH - 4.0, + 0.92, 0.96, 1.0, enabled ? 0.9 : 0.35); + gfx.drawSolidRect(iconX + iconW - 3.0, iconY + iconH * 0.35, 3.0, iconH * 0.3, + 0.92, 0.96, 1.0, enabled ? 0.9 : 0.35); + } else if (visual == "prefab") { + gfx.drawSolidRect(iconX, iconY, iconW, iconH, 0.88, 0.95, 1.0, enabled ? 0.72 : 0.25); + gfx.drawSolidRect(iconX + 2.0, iconY + 2.0, iconW - 4.0, iconH - 4.0, + r, g, b, enabled ? 0.95 : 0.35); + } else if (visual == "hitbox" || visual == "collision") { + gfx.drawSolidRect(iconX, iconY, iconW, 1.5, 1.0, 0.88, 0.55, enabled ? 0.9 : 0.35); + gfx.drawSolidRect(iconX, iconY + iconH - 1.5, iconW, 1.5, + 1.0, 0.88, 0.55, enabled ? 0.9 : 0.35); + } else if (visual == "defense") { + gfx.drawSolidRect(iconX + 2.0, iconY, iconW - 4.0, iconH * 0.7, + 0.72, 0.92, 1.0, enabled ? 0.88 : 0.32); + gfx.drawSolidRect(iconX + 4.0, iconY + iconH * 0.7, iconW - 8.0, iconH * 0.3, + 0.72, 0.92, 1.0, enabled ? 0.88 : 0.32); + } else if (visual == "movement" || visual == "input") { + gfx.drawSolidRect(iconX, iconY + iconH * 0.4, iconW, iconH * 0.2, + 0.9, 1.0, 0.78, enabled ? 0.9 : 0.32); + gfx.drawSolidRect(iconX + iconW * 0.65, iconY + iconH * 0.2, + iconW * 0.35, iconH * 0.6, 0.9, 1.0, 0.78, enabled ? 0.9 : 0.32); + } + } + if (combatEditor.timeline.getItemType(i) == "presentation:parameter-curve") { + local curveId = combatEditor.timeline.getItemId(i); + for (local sample = 0; sample <= 64; ++sample) { + local t = sample / 64.0; + local value = combatEditor.timeline.sampleParameterCurve(curveId, t); + local px = x0 + t * (x1 - x0); + local py = y1 - clampf(value, 0.0, 1.0) * (y1 - y0); + gfx.drawSolidRect(px - 1.0, py - 1.0, 2.0, 2.0, 1.0, 0.86, 0.42, 1.0); + } + } if (isState) { gfx.drawSolidRect(x0, y0, 3.0, y1 - y0, 1.0, 0.86, 0.44, 1.0); gfx.drawSolidRect(x1 - 3.0, y0, 3.0, y1 - y0, 1.0, 0.86, 0.44, 1.0); @@ -507,23 +1752,27 @@ function drawBoneTimeline() { } eve_init = function() { - buildCharacterPreview(); buildWorkspace(); ui.setTheme("dark"); ui.setScale(0.75); + buildCharacterPreview(); buildWorkspace(); buildCombatPlaytest(); ui.setTheme("dark"); ui.setScale(0.75); ui.setNavKeyboard(true); mountPanels(); combatEditor.timeline.play(); + activatePlaytestAbility(); combatEditor.status = "KayKit clip loaded · drag timeline items or use the inspector"; print("combat-action-editor: clip=" + combatEditor.clip.getDuration() + "s tracks=" + combatEditor.timeline.getTrackCount() + " items=" + combatEditor.timeline.getItemCount() + "\n"); }; eve_update = function(dt) { - handleUiEvents(); updateTimelinePointer(); updateBoneTimelinePointer(); updatePreviewCamera(); updateKeyboardShortcuts(); + handleUiEvents(); refreshDocumentToolbar(); updateTimelinePointer(); updateBoneTimelinePointer(); updatePreviewCamera(dt); updateKeyboardShortcuts(); requireResult(combatEditor.clipEditor.seekSeconds(combatEditor.timeline.getPreviewTime()), "Sync animation playhead"); if (combatEditor.timeline.isPlaying()) { - local advanced = combatEditor.timeline.update(dt); + local advanced = combatEditor.timeline.update(dt * combatEditor.playbackRate); if (!advanced.ok) combatEditor.status = advanced.status.summary; if (combatEditor.timeline.isRuntimePlaying()) { local runtimeAdvanced = combatEditor.timeline.advanceRuntime(dt); - if (!runtimeAdvanced.ok) combatEditor.status = runtimeAdvanced.status.summary; + if (!runtimeAdvanced.ok) { + combatEditor.status = runtimeAdvanced.status.summary; + print("combat-action-editor: runtime error=" + runtimeAdvanced.status.summary + "\n"); + } else { if (runtimeAdvanced.value.sectionId != "") combatEditor.status = "Runtime · " + runtimeAdvanced.value.sectionId; @@ -534,6 +1783,7 @@ eve_update = function(dt) { combatEditor.timeline.getRuntimeEventType(eventIndex) + " @ " + format("%.3f s", combatEditor.timeline.getRuntimeEventSeconds(eventIndex)); } + routeRuntimeCombatEvents(); } } } @@ -542,7 +1792,7 @@ eve_update = function(dt) { combatEditor.lastEvent = combatEditor.timeline.getEventKind(i) + " · " + combatEditor.timeline.getEventType(i) + " @ " + format("%.3f s", combatEditor.timeline.getEventTime(i)); } - updatePose(); updateLabels(); + advanceCombatPlaytest(dt); updatePose(dt); updateLabels(); }; eve_render = function() { diff --git a/examples/composable-rebellion/README.md b/examples/composable-rebellion/README.md new file mode 100644 index 00000000..c0c21305 --- /dev/null +++ b/examples/composable-rebellion/README.md @@ -0,0 +1,58 @@ +# Composable Rebellion — 引擎只存事实、玩法语义全在脚本的叛乱示例 + +本示例演示"通用能力 + 项目语义"的分层:引擎侧的 `eve.*` 模块只保存中性的属性、 +归属、授权、指令、事件等事实,完全不认识"忠诚""叛乱"这类领域概念。全部政治含义 +写在 `simulation.nut` 里,由一次拖欠军饷的薪资事件推动将领带着基地与军队倒向边疆阵营。 + +## 运行 + +```bash +make run/-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 failed: ` 便于定位;`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`:本说明。 diff --git a/examples/economy/README.md b/examples/economy/README.md new file mode 100644 index 00000000..50511b86 --- /dev/null +++ b/examples/economy/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` | 本文档 | diff --git a/examples/fluid3d/README.md b/examples/fluid3d/README.md new file mode 100644 index 00000000..bb7f6a52 --- /dev/null +++ b/examples/fluid3d/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/-debug GAME=examples/fluid3d # = 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 秒仍然存活且日志中没有错误标记。 diff --git a/examples/hex-planet/README.md b/examples/hex-planet/README.md new file mode 100644 index 00000000..22fc63ad --- /dev/null +++ b/examples/hex-planet/README.md @@ -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. diff --git a/examples/hex-planet/config.nut b/examples/hex-planet/config.nut new file mode 100644 index 00000000..03d0dbf5 --- /dev/null +++ b/examples/hex-planet/config.nut @@ -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 +}; diff --git a/examples/hex-planet/hex-planet.png b/examples/hex-planet/hex-planet.png new file mode 100644 index 00000000..9ebae154 Binary files /dev/null and b/examples/hex-planet/hex-planet.png differ diff --git a/examples/hex-planet/main.nut b/examples/hex-planet/main.nut new file mode 100644 index 00000000..476c6126 --- /dev/null +++ b/examples/hex-planet/main.nut @@ -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(); +}; diff --git a/examples/hex-planet/shaders/hex_planet_terrain.frag b/examples/hex-planet/shaders/hex_planet_terrain.frag new file mode 100644 index 00000000..121a8016 --- /dev/null +++ b/examples/hex-planet/shaders/hex_planet_terrain.frag @@ -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); +} diff --git a/examples/hex-planet/shaders/hex_planet_terrain.frag.spv b/examples/hex-planet/shaders/hex_planet_terrain.frag.spv new file mode 100644 index 00000000..0f0d8ce9 Binary files /dev/null and b/examples/hex-planet/shaders/hex_planet_terrain.frag.spv differ diff --git a/examples/hex-planet/shaders/hex_planet_water.frag b/examples/hex-planet/shaders/hex_planet_water.frag new file mode 100644 index 00000000..79b29a3c --- /dev/null +++ b/examples/hex-planet/shaders/hex_planet_water.frag @@ -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); +} diff --git a/examples/hex-planet/shaders/hex_planet_water.frag.spv b/examples/hex-planet/shaders/hex_planet_water.frag.spv new file mode 100644 index 00000000..c769d0b7 Binary files /dev/null and b/examples/hex-planet/shaders/hex_planet_water.frag.spv differ diff --git a/examples/hex-terrain-3d/README.md b/examples/hex-terrain-3d/README.md new file mode 100644 index 00000000..479a12e7 --- /dev/null +++ b/examples/hex-terrain-3d/README.md @@ -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. diff --git a/examples/hex-terrain-3d/config.nut b/examples/hex-terrain-3d/config.nut new file mode 100644 index 00000000..0e9d3d95 --- /dev/null +++ b/examples/hex-terrain-3d/config.nut @@ -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 +}; diff --git a/examples/hex-terrain-3d/hex-map.png b/examples/hex-terrain-3d/hex-map.png new file mode 100644 index 00000000..01691b61 Binary files /dev/null and b/examples/hex-terrain-3d/hex-map.png differ diff --git a/examples/hex-terrain-3d/main.nut b/examples/hex-terrain-3d/main.nut new file mode 100644 index 00000000..d9c9dcea --- /dev/null +++ b/examples/hex-terrain-3d/main.nut @@ -0,0 +1,1346 @@ +// examples/hex-terrain-3d -- interactive 3D hex map. +// +// This is the reference example for EVEngine's `hexmap` module: +// +// * the editable pointy-top cell grid, its chunk partition, the picking +// query, the mesh builder and the fog-of-war / unit / pathfinding state all +// live in C++ (`src/modules/hexmap`); +// * this script owns the camera, the input mapping, the brush tool, the +// per-chunk renderables, the unit renderable pool and the HUD. +// +// Working today: elevation with terraced slopes and cliffs, water and shore +// bands, drag-painted rivers and roads, orbit/zoom/pan camera, mouse picking +// with a hex cursor, a hex-shaped brush over five edit modes, the fog-of-war +// overlay, starter units with a planned-path preview and travel, a binary +// save/load round trip, and -- round three -- the city/farm wall, tower and +// bridge geometry plus the urban / farm / plant / special decorations, on both +// the hand-authored starter map and a procedurally generated one. +// +// The fog, the units and the explored latch are round-two additions: the module +// owns their state, this script only reads samples and pushes the results into +// renderables. Round three keeps that split: the module owns the wall flags, the +// feature levels and the generator, and this script only calls the setters and +// binds the two new surface streams. + +// Surface streams produced by the C++ mesh builder, in `hexmap.HexSurface` order. +const SURFACE_TERRAIN = 0; +const SURFACE_WATER = 1; +const SURFACE_RIVER = 2; +const SURFACE_ROAD = 3; +const SURFACE_FOG = 4; +const SURFACE_WALL = 5; +const SURFACE_FEATURE = 6; +const SURFACE_COUNT = 7; + +// Procedural map tunables, passed straight through to `hexmap.generateMap` and +// identical to the module's own `HexMapGeneratorSettings` defaults. +const SEED_LAND_PERCENT = 50; // target share of the map left above the water line +const SEED_WATER_LEVEL = 3; // cells at or below this elevation are flooded +const SEED_RIVER_PERCENT = 10; // target share of the map covered by rivers + +// Hand-authored walled district for the starter map. It is a (2r+1) square of +// walled cells whose centre is offset from the map centre, so the same two numbers +// place it on every entry in `MAP_SIZES`. The wall mesh only draws the borders a +// walled cell shares with an unwalled one, so the filled square renders as an +// enclosing ring; the road corridor cut through it opens a gate on each side. +const DISTRICT_RADIUS = 2; // block half-extent in cells: a (2r+1) square +const DISTRICT_SETBACK = 0; // shift of the block's centre from the map centre + +// Brush modes. +const MODE_ELEVATION = 0; +const MODE_WATER = 1; +const MODE_TERRAIN = 2; +const MODE_RIVER = 3; +const MODE_ROAD = 4; +const MODE_COUNT = 5; + +// Terrain palette order matches the reference editor: Sand, Grass, Mud, Stone, Snow. +TERRAIN_NAMES <- ["Sand", "Grass", "Mud", "Stone", "Snow"]; + +// Selectable map sizes; the grid requires multiples of the 5x5 chunk size. +MAP_SIZES <- [20, 30, 40]; +const MAX_CHUNK_COLUMNS = 8; + +// Units. +// +// Unit ids are positional inside the module (a removal shifts every later id +// down by one), so the renderable pool is indexed by unit id directly instead of +// by a second id-to-slot table: slot `i` always draws unit `i`, and slots past +// `unitCount()` are hidden. +const MAX_UNITS = 16; +const UNIT_SCALE = 1.6; // scale of the shared 1x1x1 cube mesh +const UNIT_LIFT = 2.2; // cube centre above the cell surface +const UNIT_TURN = 0.0; // starter facing, in degrees +const PATH_LIFT = 0.45; // keeps the planned path just above the hex tops +const DEG_TO_RAD = 0.017453292519943295; + +// Camera ranges follow the reference HexMapCamera, but the far distance is +// derived from the map extent: the reference ships a 20x15 editor map, while this +// example can build a 40x40 one whose diagonal is far wider than 250 world units. +// Without that, the far end of the zoom range could never frame the map. +const ZOOM_NEAR_DISTANCE = 45.0; +const ZOOM_MIN_FAR = 250.0; +const ZOOM_FAR_MARGIN = 1.55; // far distance as a multiple of the map span +const ZOOM_NEAR_SWIVEL = 45.0; +const ZOOM_FAR_SWIVEL = 62.0; // never fully horizontal: a flat map goes edge-on +const ZOOM_NEAR_SPEED = 100.0; +const ZOOM_FAR_SPEED = 400.0; +const ROTATE_SPEED = 90.0; +const ZOOM_STEP = 0.09; +// Framing: the camera must sit back far enough for the map to fit the vertical +// field of view, with a little margin. +const CAMERA_FOV_DEGREES = 50.0; +const ZOOM_FIT_MARGIN = 1.15; + +// Hex geometry, mirrored from the C++ metrics (outerRadius 10). +const HEX_OUTER = 10.0; +const HEX_INNER = 8.6602540378; + +persist hexMap = null; + +function createHexState() { + return { + renderables = [], // flat pool: (chunkRow * MAX_CHUNK_COLUMNS + chunkColumn) * SURFACE_COUNT + surface + configured = [], + shaders = [], + highlight = null, + camera = null, + yaw = 35.0, + zoom = 0.5, + pivotX = 0.0, + pivotZ = 0.0, + sizeIndex = 0, + seed = 20260915, + mode = MODE_ELEVATION, + brushRadius = 1, + terrainType = 1, + hoverX = -1, + hoverZ = -1, + hasHover = false, + hoverChanged = true, + dragX = -1, + dragZ = -1, + dragging = false, + elapsed = 0.0, + frameCount = 0, + screenshotSaved = false, + meshCount = 0, + statusText = "building...", + uiBuilt = false, + lastLeft = false, + lastRight = false, + + // Round-two state: one shared unit mesh plus a pool of unit renderables, + // the selected unit id, the planned path and the save file path. + unitMesh = null, + unitRenderables = [], + selectedId = 0, + selectedTraveling = false, + selectedCellX = -1, + selectedCellZ = -1, + pathPreview = [], + pathDirty = false, + pathLine = null, + savePath = "", + saveFs = null, + }; +} + +// --- camera ----------------------------------------------------------------- + +/** Largest XZ extent of the current map, in world units; 0 when no grid exists. */ +function mapSpan(st) { + if (!hexmap.hasGrid()) return 0.0; + local lastX = hexmap.cellCountX() - 1; + local lastZ = hexmap.cellCountZ() - 1; + local spanX = hexmap.cellPositionX(lastX, lastZ) - hexmap.cellPositionX(0, 0); + local spanZ = hexmap.cellPositionZ(lastX, lastZ) - hexmap.cellPositionZ(0, 0); + return spanX > spanZ ? spanX : spanZ; +} + +function cameraFarDistance(st) { + local needed = mapSpan(st) * ZOOM_FAR_MARGIN; + return needed > ZOOM_MIN_FAR ? needed : ZOOM_MIN_FAR; +} + +function cameraDistance(st) { + // zoom 0 = closest, zoom 1 = farthest. + local near = ZOOM_NEAR_DISTANCE; + local far = cameraFarDistance(st); + return near + (far - near) * st.zoom; +} + +function cameraSwivel(st) { + return ZOOM_NEAR_SWIVEL + (ZOOM_FAR_SWIVEL - ZOOM_NEAR_SWIVEL) * st.zoom; +} + +/** + * Zoom that frames the whole map: the distance at which the map span fits the + * vertical field of view, converted back into the 0..1 zoom range. + */ +function framingZoom(st) { + local span = mapSpan(st); + if (span <= 0.0) return 0.35; + local near = ZOOM_NEAR_DISTANCE; + local far = cameraFarDistance(st); + local halfFov = CAMERA_FOV_DEGREES * 0.5 * 0.017453292519943295; + local wanted = span * ZOOM_FIT_MARGIN / (2.0 * tan(halfFov)); + return clampf((wanted - near) / (far - near), 0.0, 1.0); +} + +function cameraMoveSpeed(st) { + return ZOOM_NEAR_SPEED + (ZOOM_FAR_SPEED - ZOOM_NEAR_SPEED) * st.zoom; +} + +function applyCamera(st) { + if (st.camera == null) return; + local distance = cameraDistance(st); + local swivel = cameraSwivel(st) * 0.017453292519943295; + local yaw = st.yaw * 0.017453292519943295; + local horizontal = distance * cos(swivel); + // `eve.Camera3D()` defaults to a 0.1..100 clip range, which is smaller than a + // single map span once the grid grows past a few chunks: everything beyond 100 + // units was silently clipped away, so the far end of the zoom range showed an + // empty frame. Track the map extent instead of hard-coding a range. + local far = distance + mapSpan(st) * 1.5 + 100.0; + st.camera.setClipPlanes(0.5, far); + st.camera.setEye(st.pivotX - horizontal * sin(yaw), + distance * sin(swivel), + st.pivotZ - horizontal * cos(yaw)); + st.camera.setTarget(st.pivotX, 0.0, st.pivotZ); +} + +function clampPivot(st) { + if (!hexmap.hasGrid()) return; + local lastX = hexmap.cellCountX() - 1; + local lastZ = hexmap.cellCountZ() - 1; + local margin = 30.0; + local minX = hexmap.cellPositionX(0, 0) - margin; + local maxX = hexmap.cellPositionX(lastX, lastZ) + margin; + local minZ = hexmap.cellPositionZ(0, 0) - margin; + local maxZ = hexmap.cellPositionZ(lastX, lastZ) + margin; + st.pivotX = clampf(st.pivotX, minX, maxX); + st.pivotZ = clampf(st.pivotZ, minZ, maxZ); +} + +function updateCamera(st, dt) { + local speed = cameraMoveSpeed(st); + // Reference panning: camera-relative on the XZ plane. + local yaw = st.yaw * 0.017453292519943295; + local forwardX = -sin(yaw), forwardZ = -cos(yaw); + local rightX = cos(yaw), rightZ = -sin(yaw); + local move = speed * dt; + if (keyboard.isDown("w") || keyboard.isDown("up")) { + st.pivotX += forwardX * move; st.pivotZ += forwardZ * move; + } + if (keyboard.isDown("s") || keyboard.isDown("down")) { + st.pivotX -= forwardX * move; st.pivotZ -= forwardZ * move; + } + if (keyboard.isDown("d") || keyboard.isDown("right")) { + st.pivotX += rightX * move; st.pivotZ += rightZ * move; + } + if (keyboard.isDown("a") || keyboard.isDown("left")) { + st.pivotX -= rightX * move; st.pivotZ -= rightZ * move; + } + if (keyboard.isDown("q")) st.yaw += ROTATE_SPEED * dt; + if (keyboard.isDown("e")) st.yaw -= ROTATE_SPEED * dt; + if (keyboard.isDown("z")) st.zoom = clampf(st.zoom - dt * 0.6, 0.0, 1.0); + if (keyboard.isDown("x")) st.zoom = clampf(st.zoom + dt * 0.6, 0.0, 1.0); + + local wheel = mouse.getWheelY(); + if (wheel != 0.0) st.zoom = clampf(st.zoom - wheel * ZOOM_STEP, 0.0, 1.0); + + clampPivot(st); + applyCamera(st); +} + +// --- shaders and renderables ------------------------------------------------ + +function createShaders(st) { + if (st.shaders.len() > 0) return; + // The engine's built-in Mesh3D vertex stage already produces the varyings these + // fragment shaders consume, so only the fragment stages are shipped (as SPIR-V: + // runtime GLSL compilation is unavailable on Windows). The list is indexed by + // surface stream, so it must stay in `HexSurface` order; the wall and the + // feature stream share one shader, which is therefore listed twice. + local frags = ["shaders/hex_map_terrain.frag.spv", "shaders/hex_map_water.frag.spv", + "shaders/hex_map_river.frag.spv", "shaders/hex_map_road.frag.spv", + "shaders/hex_map_fog.frag.spv", "shaders/hex_map_feature.frag.spv", + "shaders/hex_map_feature.frag.spv"]; + for (local s = 0; s < SURFACE_COUNT; s++) { + local loaded = gfx.loadMeshShaderSpv("", frags[s]); + if (!loaded.ok) { + print("hex map: shader " + frags[s] + " failed: " + loaded.status.summary + "\n"); + st.shaders.append(null); + continue; + } + st.shaders.append(loaded.value); + } +} + +function renderableSlot(chunkColumn, chunkRow, surface) { + return (chunkRow * MAX_CHUNK_COLUMNS + chunkColumn) * SURFACE_COUNT + surface; +} + +function ensureRenderable(st, slot) { + while (st.renderables.len() <= slot) { + st.renderables.append(null); + st.configured.append(false); + } + if (st.renderables[slot] == null) { + local r = eve.Renderable3D(); + r.setVisible(false); + st.renderables[slot] = r; + } + return st.renderables[slot]; +} + +function hideAllRenderables(st) { + foreach (r in st.renderables) { + if (r != null) r.setVisible(false); + } +} + +/** Hides every pooled unit renderable; `updateUnits` shows the live ones again. */ +function hideUnitRenderables(st) { + for (local id = 0; id < st.unitRenderables.len(); id++) + st.unitRenderables[id].setVisible(false); +} + +function syncChunk(st, chunkIndex) { + local columns = hexmap.chunkCountX(); + local column = chunkIndex % columns; + local row = chunkIndex / columns; + // The loop covers all seven streams, so adding `Wall` and `Feature` needed no + // change here: `renderableSlot` folds `SURFACE_COUNT` in, and the mesh lookup + // is by surface index, not by name. + for (local s = 0; s < SURFACE_COUNT; s++) { + local slot = renderableSlot(column, row, s); + local r = ensureRenderable(st, slot); + if (!st.configured[slot]) { + // Bind the surface shader and shadow flags exactly once per pool slot. + // The length guard only matters for a session hot-reloaded from a script + // revision with fewer surfaces; a fresh run always fills all seven. + local shader = (s < st.shaders.len()) ? st.shaders[s] : null; + if (shader != null) r.setShader(shader); + // The terrain casts onto everything else but does not receive: its very thin + // blend/terrace quads self-shadow as a sawtooth acne band along every hex edge. + // Water, river, road, the opaque fog columns and both new streams (walls and + // decorations are small free-standing geometry) neither cast nor receive, so + // only the terrain slot gets either flag. + r.setCastShadow(s == SURFACE_TERRAIN); + r.setReceiveShadow(s == SURFACE_ROAD); + st.configured[slot] = true; + } + local mesh = hexmap.chunkMeshAt(chunkIndex, s); + if (mesh == null) { + r.setVisible(false); + } else { + r.setMesh(mesh); + r.setVisible(true); + } + } +} + +function rebuildAllChunks(st) { + local rebuilt = 0; + for (;;) { + local chunkIndex = hexmap.takeDirtyChunk(); + if (chunkIndex < 0) break; + rebuilt += hexmap.rebuildChunk(gfx, chunkIndex); + syncChunk(st, chunkIndex); + } + st.meshCount = rebuilt; +} + +/** + * Rebuilds the dirty chunks, if any. + * + * Fog visibility is module-owned and already marks the affected chunks dirty when + * a unit is added/removed, walks into a new cell or when the explored latch flips, + * so the frame loop must not mark whole chunks dirty by hand: it only has to drain + * whatever the module flagged. + */ +function updateDirtyChunks(st) { + if (hexmap.dirtyChunkCount() <= 0) return; + rebuildAllChunks(st); +} + +function hideUnusedRenderables(st) { + local columns = hexmap.chunkCountX(); + local rows = hexmap.chunkCountZ(); + // Also folded on `SURFACE_COUNT`, so the two new streams are covered too. + for (local row = 0; row < MAX_CHUNK_COLUMNS; row++) { + for (local column = 0; column < MAX_CHUNK_COLUMNS; column++) { + if (row < rows && column < columns) continue; + for (local s = 0; s < SURFACE_COUNT; s++) { + local slot = renderableSlot(column, row, s); + if (slot < st.renderables.len() && st.renderables[slot] != null) + st.renderables[slot].setVisible(false); + } + } + } +} + +// --- map lifecycle ---------------------------------------------------------- + +function buildMap(st) { + hideAllRenderables(st); + hideUnitRenderables(st); + local size = MAP_SIZES[st.sizeIndex]; + local created = hexmap.newGrid(gfx, size, size, st.seed); + if (!created.ok) { + st.statusText = "newGrid failed: " + created.status.summary; + print("hex map: " + st.statusText + "\n"); + return; + } + createShaders(st); + seedStarterMap(st); + exploreAll(st); + placeStarterUnits(st); + rebuildAllChunks(st); + hideUnusedRenderables(st); + + st.pivotX = (hexmap.cellPositionX(0, 0) + hexmap.cellPositionX(size - 1, size - 1)) * 0.5; + st.pivotZ = (hexmap.cellPositionZ(0, 0) + hexmap.cellPositionZ(size - 1, size - 1)) * 0.5; + st.zoom = framingZoom(st); + st.hoverX = -1; st.hoverZ = -1; st.hasHover = false; + clearPathPreview(st); + st.selectedId = 0; + st.statusText = "chunks " + hexmap.chunkCountX() + "x" + hexmap.chunkCountZ(); + + print("hex map ready: " + hexmap.cellCountX() + "x" + hexmap.cellCountZ() + " cells, " + + hexmap.chunkCount() + " chunks, " + hexmap.unitCount() + " units\n"); +} + +/** + * Replaces the grid with a procedurally generated map at the current size. + * + * `generateMap` re-seeds the grid at the size that is already set, releases every + * chunk mesh and drops all units and all fog state. Its contract then mirrors the + * end of `buildMap` exactly, so this runs the same sequence: the caller places new + * units, re-explores (a generated map starts with no explored cell, and the + * pathfinder refuses to enter one), rebuilds every chunk -- the module marked them + * all dirty, and no renderable points at the new meshes yet -- and hides the pool + * slots outside the new chunk count. The generator honours the generator seed, so + * the same seed and the same size always produce the same map. + */ +function reseedProceduralMap(st) { + st.seed = procgen.randomSeed(); + local generated = hexmap.generateMap(gfx, st.seed, SEED_LAND_PERCENT, SEED_WATER_LEVEL, SEED_RIVER_PERCENT); + if (!generated.ok) { + st.statusText = "generate failed: " + generated.status.summary; + print("hex map: " + st.statusText + "\n"); + return; + } + + // The map size did not change, so `sizeIndex` stays valid; the module already + // released the old meshes, so every pool slot has to be re-pointed below. + hideAllRenderables(st); + hideUnitRenderables(st); + st.hasHover = false; + st.hoverX = -1; st.hoverZ = -1; + clearPathPreview(st); + exploreAll(st); + placeStarterUnits(st); + hexmap.markAllChunksDirty(); + rebuildAllChunks(st); + hideUnusedRenderables(st); + + local size = MAP_SIZES[st.sizeIndex]; + st.pivotX = (hexmap.cellPositionX(0, 0) + hexmap.cellPositionX(size - 1, size - 1)) * 0.5; + st.pivotZ = (hexmap.cellPositionZ(0, 0) + hexmap.cellPositionZ(size - 1, size - 1)) * 0.5; + st.zoom = framingZoom(st); + st.statusText = "generated seed " + st.seed + ", " + hexmap.chunkCountX() + "x" + hexmap.chunkCountZ() + + " chunks, " + hexmap.unitCount() + " units"; + print("hex map: " + st.statusText + "\n"); +} + +// --- picking ---------------------------------------------------------------- + +function updateHover(st) { + local previousX = st.hoverX, previousZ = st.hoverZ; + st.hasHover = false; + if (hexmap.hasGrid() && st.camera != null) { + st.camera.screenToRay(mouse.getX(), mouse.getY(), + gfx.getWidth().tofloat(), gfx.getHeight().tofloat()); + local hit = hexmap.pickCell(st.camera.getScreenRayOriginX(), st.camera.getScreenRayOriginY(), + st.camera.getScreenRayOriginZ(), st.camera.getScreenRayDirX(), + st.camera.getScreenRayDirY(), st.camera.getScreenRayDirZ()); + if (hit.ok) { + st.hoverX = hit.value[0].tointeger(); + st.hoverZ = hit.value[1].tointeger(); + st.hasHover = true; + } + } + st.hoverChanged = (st.hoverX != previousX) || (st.hoverZ != previousZ); +} + +function updateHighlight(st) { + if (st.highlight == null) return; + if (!st.hasHover) { + st.highlight.setVisible(false); + return; + } + local cx = hexmap.cellPositionX(st.hoverX, st.hoverZ); + local cy = hexmap.cellPositionY(st.hoverX, st.hoverZ) + 0.18; + local cz = hexmap.cellPositionZ(st.hoverX, st.hoverZ); + local points = [ + cx, cy, cz + HEX_OUTER, + cx + HEX_INNER, cy, cz + 0.5 * HEX_OUTER, + cx + HEX_INNER, cy, cz - 0.5 * HEX_OUTER, + cx, cy, cz - HEX_OUTER, + cx - HEX_INNER, cy, cz - 0.5 * HEX_OUTER, + cx - HEX_INNER, cy, cz + 0.5 * HEX_OUTER + ]; + st.highlight.setPolyline(points, true); + st.highlight.setVisible(true); +} + +// --- hex cell addressing ---------------------------------------------------- +// +// The module addresses cells with odd-row offset `(column, row)` pairs; the +// step table is defined on axial coordinates, so every neighbour query converts +// offset -> axial -> offset. + +function axialX(offsetX, offsetZ) { + return offsetX - offsetZ / 2; +} + +function neighborOffset(x, z, direction) { + local ax = axialX(x, z), az = z; + if (direction == 0) az += 1; // NE + else if (direction == 1) ax += 1; // E + else if (direction == 2) { ax += 1; az -= 1; } // SE + else if (direction == 3) az -= 1; // SW + else if (direction == 4) ax -= 1; // W + else { ax -= 1; az += 1; } // NW + if (az < 0) return null; + return [ax + az / 2, az]; +} + +function directionBetween(ax, az, bx, bz) { + local dx = axialX(bx, bz) - axialX(ax, az), dz = bz - az; + if (dx == 0 && dz == 1) return 0; // NE + if (dx == 1 && dz == 0) return 1; // E + if (dx == 1 && dz == -1) return 2; // SE + if (dx == 0 && dz == -1) return 3; // SW + if (dx == -1 && dz == 0) return 4; // W + if (dx == -1 && dz == 1) return 5; // NW + return -1; +} + +// --- starter scene ---------------------------------------------------------- + +/** Carves a river from `(x, z)` by repeatedly flowing to the lowest neighbour. */ +function carveRiver(st, x, z, maxSteps) { + local size = MAP_SIZES[st.sizeIndex]; + local previousX = -1, previousZ = -1; + // A channel that re-enters a cell it already carved would call + // `setOutgoingRiver` again, and that removes the cell's previous connection + // first --?which erases the stretch of channel behind it. Remembering the + // visited cells turns any such cycle into a clean stop. A linear scan is used + // rather than a table keyed by the cell, because a plain array is the only + // container this script dialect is guaranteed to have. + local visited = []; + for (local step = 0; step < maxSteps; step++) { + visited.append(x * 1000 + z); + // The lowest neighbour wins. `bestElevation` must start above every real + // elevation, not at the current cell's: on flat ground (which is most of this + // map) no neighbour is strictly lower, and seeding it with the current + // elevation made the search give up on step zero and carve nothing at all. + local bestDirection = -1, bestElevation = 1000; + for (local d = 0; d < 6; d++) { + local n = neighborOffset(x, z, d); + if (n == null) continue; + if (n[0] < 0 || n[1] < 0 || n[0] >= size || n[1] >= size) continue; + // Never step straight back: two cells at the same elevation would + // otherwise bounce the channel between them for the whole budget. + if (n[0] == previousX && n[1] == previousZ) continue; + if (visited.find(n[0] * 1000 + n[1]) != null) continue; + local e = hexmap.elevation(n[0], n[1]); + if (e < bestElevation) { bestElevation = e; bestDirection = d; } + } + // A river may cross level ground, but it may never flow uphill; the module + // rejects that itself, so stopping here keeps the failure off the log. + if (bestDirection < 0) break; + if (bestElevation > hexmap.elevation(x, z)) break; + local next = neighborOffset(x, z, bestDirection); + // Flowing into an existing channel is a confluence, not a continuation. + local joinsExisting = hexmap.hasRiver(next[0], next[1]); + if (!hexmap.setOutgoingRiver(x, z, bestDirection).ok) break; + previousX = x; + previousZ = z; + x = next[0]; + z = next[1]; + if (joinsExisting) break; + } +} + +/** + * Walls or unwalls a solid rectangular block of cells. + * + * The wall mesh stream draws the borders between a walled and an unwalled cell, so + * a filled block yields the enclosing ring: the cells outside it stay unwalled and + * the interior borders are never emitted. A block crossed by a road therefore + * leaves a gap in the ring exactly where the road enters and leaves. + */ +function wallBlock(st, x0, z0, x1, z1, walled) { + for (local z = z0; z <= z1; z++) { + for (local x = x0; x <= x1; x++) + hexmap.setWalled(x, z, walled); + } +} + +/** + * Builds the deterministic starter map so the terrain, terrace, cliff, water, + * river, road, wall and decoration systems are all visible on the first frame. + * + * The module ships a procedural generator; this hand-authored map is the stand-in + * for it and `[M]` runs the real generator on demand. The walled district below + * deliberately reproduces the shape the generator produces for a walled city -- + * a ring of walled cells with a road corridor running through it and urban / farm + * / plant / special features around it -- so both paths exercise the same geometry. + */ +function seedStarterMap(st) { + local size = MAP_SIZES[st.sizeIndex]; + local mid = size / 2; + + // Fill every terrain band first, then raise and flood features over it. + for (local z = 0; z < size; z++) { + for (local x = 0; x < size; x++) + hexmap.setTerrainType(x, z, ((x / 6) + (z / 6)) % TERRAIN_NAMES.len()); + } + + // A two-step central plateau: elevation 2 over radius 6, elevation 5 over radius 2. + // Terrain is painted widest-first so the narrower bands stay on top. + hexmap.editElevation(mid, mid, 6, 2); + hexmap.editElevation(mid, mid, 2, 3); + hexmap.editTerrainType(mid, mid, 5, 1); + hexmap.editTerrainType(mid, mid, 2, 3); + + // A flooded basin in the south-west corner: carved down, then filled. + local basinX = 3, basinZ = size - 4; + hexmap.editElevation(basinX, basinZ, 3, -2); + hexmap.editTerrainType(basinX, basinZ, 3, 2); + hexmap.editWaterLevel(basinX, basinZ, 3, 2); + + // Rivers run off the plateau to the map edge. + carveRiver(st, mid, mid + 7, 40); + carveRiver(st, mid + 7, mid - 5, 40); + + // A road crossing the lowland, one west-east line and one north-south spur. + // The spur is the one that threads the walled district below. + for (local x = 1; x + 1 < size; x++) hexmap.addRoad(x, size / 4, 1); + for (local z = size / 4; z < size - 2; z++) hexmap.addRoad(mid + 8, z, 3); + + // --- round three: the walled district and its decorations --- + // + // A square block of walled cells centred on the plateau, so the ring sits on + // flat land and the road spur at column `mid + 8` enters and leaves through the + // ring's east side: those two borders are where the wall mesh must leave the + // road gap instead of drawing a segment. The block corners are also where the + // tower pieces appear. + local district = mid + DISTRICT_SETBACK; + local radius = DISTRICT_RADIUS; + wallBlock(st, district - radius, district - radius, district + radius, district + radius, true); + + // The road spur the district was built around runs at column `mid + 8`, one + // column east of the block. A road has to exist on *both* sides of a wall + // border for the wall mesh to open a gate there, so the spur is continued + // straight through the block: it now enters the ring's east side and runs out + // through its west side, and those two borders are the gates. Every wall piece + // the ring would otherwise draw across the road is skipped by the mesh builder. + for (local z = district - radius; z <= district + radius; z++) { + for (local x = district - radius; x < district + radius + 1; x++) + hexmap.addRoad(x, z, 1); + } + + // Urban fabric inside the district, farmland on the low ground south of the + // plateau, and vegetation on the plateau shoulders. Setting a feature level + // never touches the terrain, so an already-rivered or flooded cell simply ends + // up with the decoration the module picks for it. The block's middle row is the + // road corridor, so every feature here sits off it. + hexmap.setUrbanLevel(district - 1, district - 1, 3); + hexmap.setUrbanLevel(district, district + 1, 2); + hexmap.setUrbanLevel(district + 1, district - 1, 2); + hexmap.setUrbanLevel(district - 1, district + 1, 1); + hexmap.setFarmLevel(district + 3, district + 2, 3); + hexmap.setFarmLevel(district + 3, district + 3, 2); + hexmap.setFarmLevel(district + 4, district + 2, 2); + hexmap.setFarmLevel(district - 4, district + 2, 1); + hexmap.setPlantLevel(district, district - 1, 2); + hexmap.setPlantLevel(district + 2, district + 1, 2); + hexmap.setPlantLevel(district - 3, district - 1, 2); + hexmap.setPlantLevel(district - 1, district + radius + 1, 1); + + // One special feature inside the ring, clear of the road corridor through the + // middle: a special feature suppresses the cell's ordinary decoration, and the + // module drops the cell's roads when one is set, so it is placed off the + // carriageway rather than on it. + hexmap.setSpecialIndex(district + 1, district + 1, 1); +} + +function applyBrush(st, raise) { + if (!st.hasHover) return; + local delta = raise ? 1 : -1; + local radius = st.brushRadius; + local x = st.hoverX, z = st.hoverZ; + if (st.mode == MODE_ELEVATION) { + hexmap.editElevation(x, z, radius, delta); + } else if (st.mode == MODE_WATER) { + hexmap.editWaterLevel(x, z, radius, delta); + } else if (st.mode == MODE_TERRAIN) { + if (raise) hexmap.editTerrainType(x, z, radius, st.terrainType); + } else if (st.mode == MODE_RIVER) { + if (!raise) { hexmap.removeRiver(x, z); } + } else if (st.mode == MODE_ROAD) { + if (!raise) { hexmap.removeRoads(x, z); } + } +} + +function paintDrag(st, x, z) { + // Rivers and roads are painted by dragging from a cell to its neighbour, + // matching the reference editor. + if (st.dragX < 0) { st.dragX = x; st.dragZ = z; return; } + local direction = directionBetween(st.dragX, st.dragZ, x, z); + if (direction < 0) { st.dragX = x; st.dragZ = z; return; } + if (st.mode == MODE_RIVER) { + hexmap.setOutgoingRiver(st.dragX, st.dragZ, direction); + } else if (st.mode == MODE_ROAD) { + hexmap.addRoad(st.dragX, st.dragZ, direction); + } + st.dragX = x; + st.dragZ = z; +} + +function updateBrush(st) { + local left = mouse.isDown(1); + local right = mouse.isDown(2); + local leftPressed = left && !st.lastLeft; + local rightPressed = right && !st.lastRight; + st.lastLeft = left; + st.lastRight = right; + + if (st.uiBuilt && ui.wantCaptureMouse()) return; + + local dragMode = (st.mode == MODE_RIVER || st.mode == MODE_ROAD); + if (!left) { + st.dragging = false; + st.dragX = -1; + st.dragZ = -1; + } + + if (st.hasHover) { + if (dragMode) { + if (left) { + st.dragging = true; + if (leftPressed || st.hoverChanged) paintDrag(st, st.hoverX, st.hoverZ); + } else if (rightPressed) { + applyBrush(st, false); + } + } else if (st.mode == MODE_TERRAIN) { + if (left && (leftPressed || st.hoverChanged)) applyBrush(st, true); + if (right && (rightPressed || st.hoverChanged)) hexmap.removeRoads(st.hoverX, st.hoverZ); + } else { + if (left && (leftPressed || st.hoverChanged)) applyBrush(st, true); + if (right && (rightPressed || st.hoverChanged)) applyBrush(st, false); + } + } + + if (leftPressed || rightPressed || (left && st.hoverChanged) || (right && st.hoverChanged)) { + rebuildAllChunks(st); + } +} + +// --- fog of war ------------------------------------------------------------- +// +// The module owns the explored latch and the per-cell visibility counters; the +// script only reads them (`isExplored` / `isExplorable` / `isCellVisible`) and +// flips them with `setExplored` / `setExplorable`. The fog overlay itself is an +// ordinary surface stream (`HexSurface::Fog`), so it needs no extra renderable +// setup beyond the shader it is bound to in `syncChunk`. + +/** + * Latches every cell of the freshly generated grid as explored. + * + * A search may only enter explored *and* explorable cells (`isValidDestination`), + * so an un-explored grid cannot be pathed through at all: no path ever leaves the + * origin. The editor therefore starts with the whole map "known" -- the fog + * overlay still dims every cell no unit currently sees -- and `[J]` flips a single + * cell back to unexplored to show the latch. + */ +function exploreAll(st) { + local size = MAP_SIZES[st.sizeIndex]; + for (local z = 0; z < size; z++) { + for (local x = 0; x < size; x++) + hexmap.setExplored(x, z, true); + } +} + +/** Toggles the explored latch of the hovered cell (and therefore its fog shade). */ +function toggleHoverExplored(st) { + if (!st.hasHover) { st.statusText = "explored: no cell under the cursor"; return; } + local explored = !hexmap.isExplored(st.hoverX, st.hoverZ); + local changed = hexmap.setExplored(st.hoverX, st.hoverZ, explored); + if (!changed.ok) { st.statusText = "explored failed: " + changed.status.summary; return; } + st.statusText = "cell (" + st.hoverX + ", " + st.hoverZ + ") explored=" + explored; +} + +/** + * Toggles whether the hovered cell may be entered at all. + * + * `isExplorable` is the second half of the search's destination test, so clearing + * it makes the cell -- and everything only reachable through it -- unreachable. + */ +function toggleHoverExplorable(st) { + if (!st.hasHover) { st.statusText = "explorable: no cell under the cursor"; return; } + local explorable = !hexmap.isExplorable(st.hoverX, st.hoverZ); + local changed = hexmap.setExplorable(st.hoverX, st.hoverZ, explorable); + if (!changed.ok) { st.statusText = "explorable failed: " + changed.status.summary; return; } + st.statusText = "cell (" + st.hoverX + ", " + st.hoverZ + ") explorable=" + explorable; +} + +// --- units ------------------------------------------------------------------ + +/** + * Builds the shared unit mesh and the renderable pool once. + * + * Every pool entry is a cube with its own tint; the mesh itself is shared, and the + * pool is indexed by unit id so no id-to-slot bookkeeping is needed. The cube is + * stretched along its local Z axis so `setYaw` (driven by the unit's orientation) + * is visible on screen. + */ +function createUnitPool(st) { + if (st.unitRenderables.len() > 0) return; + st.unitMesh = gfx.newMeshCube(1.0); + for (local index = 0; index < MAX_UNITS; index++) { + local r = eve.Renderable3D(); + r.setMesh(st.unitMesh); + r.setScale(UNIT_SCALE, UNIT_SCALE * 1.15, UNIT_SCALE * 1.9); + local tint = unitTint(index); + r.setTint(tint[0], tint[1], tint[2], 1.0); + r.setCastShadow(true); + r.setReceiveShadow(false); + r.setVisible(false); + st.unitRenderables.append(r); + } +} + +/** Distinct, deterministic tint per pool slot so two units never look alike. */ +function unitTint(index) { + local phase = index % 6; + local bright = ((index / 6) % 2 == 0) ? 1.0 : 0.62; + local r = 0.0, g = 0.0, b = 0.0; + if (phase == 0) r = 1.0; + else if (phase == 1) { r = 1.0; g = 0.72; } + else if (phase == 2) g = 1.0; + else if (phase == 3) { g = 0.85; b = 1.0; } + else if (phase == 4) b = 1.0; + else { r = 0.72; b = 1.0; } + return [r * bright, g * bright, b * bright]; +} + +/** + * Searches outwards from `(cx, cz)` for a cell a unit may stand on. + * + * The module rejects an occupied cell, so the ring scan also skips cells another + * unit already holds; underwater cells are skipped because a unit may not be + * added there in the first place. + * + * @return `[x, z]` of a free land cell, or null when the ring is exhausted. + */ +function findFreeLandCell(st, cx, cz, maxRadius) { + local size = MAP_SIZES[st.sizeIndex]; + for (local radius = 0; radius <= maxRadius; radius++) { + for (local dz = -radius; dz <= radius; dz++) { + for (local dx = -radius; dx <= radius; dx++) { + if (radius > 0 && dx > -radius && dx < radius && dz > -radius && dz < radius) continue; + local x = cx + dx, z = cz + dz; + if (x < 0 || z < 0 || x >= size || z >= size) continue; + if (hexmap.isUnderwater(x, z)) continue; + if (hexmap.unitIdAt(x, z) >= 0) continue; + return [x, z]; + } + } + } + return null; +} + +/** + * Places the starter units near the middle of the map. + * + * The offsets are a flat `[dx, dz, ...]` list (no nested array literals): each + * candidate is nudged to the nearest free land cell, so the seed always produces + * three units even when the plateau's rivers or the flooded basin sit in the way. + */ +function placeStarterUnits(st) { + hexmap.removeAllUnits(); + local size = MAP_SIZES[st.sizeIndex]; + local mid = size / 2; + local offsets = [0, 0, 2, -1, -2, 1, 1, 2, -1, -2]; + local placed = 0; + for (local i = 0; i + 1 < offsets.len() && placed < 3; i += 2) { + local found = findFreeLandCell(st, mid + offsets[i], mid + offsets[i + 1], 6); + if (found == null) continue; + local added = hexmap.addUnit(found[0], found[1], UNIT_TURN); + if (!added.ok) { + print("hex map: starter unit at (" + found[0] + ", " + found[1] + ") failed: " + + added.status.summary + "\n"); + continue; + } + placed += 1; + } + st.selectedId = 0; +} + +/** + * Pushes one `unitSample` per live pool slot into its renderable. + * + * Unit ids are positional: slot `id` always draws unit `id`, and every slot from + * `unitCount()` up is hidden. A removal therefore never leaves a stale renderable + * behind, but the selected id may point at a different unit afterwards, which is + * why it is clamped here instead of at the call sites. + */ +function updateUnits(st) { + if (st.unitRenderables.len() == 0) return; + local count = hexmap.unitCount(); + if (count <= 0) st.selectedId = -1; + else if (st.selectedId < 0 || st.selectedId >= count) st.selectedId = 0; + + st.selectedTraveling = false; + st.selectedCellX = -1; + st.selectedCellZ = -1; + for (local id = 0; id < st.unitRenderables.len(); id++) { + local r = st.unitRenderables[id]; + if (id >= count) { r.setVisible(false); continue; } + local sample = hexmap.unitSample(id); + if (!sample.ok) { r.setVisible(false); continue; } + local v = sample.value; + r.setPosition(v[2], v[3] + UNIT_LIFT, v[4]); + r.setYaw(v[5] * DEG_TO_RAD); + r.setVisible(true); + if (id == st.selectedId) { + st.selectedCellX = v[0]; + st.selectedCellZ = v[1]; + st.selectedTraveling = (v[6] != 0); + } + } +} + +// --- planned path and travel ------------------------------------------------ + +function clearPathPreview(st) { + st.pathPreview = []; + st.pathDirty = true; +} + +/** + * Plans a path from the selected unit to the hovered cell and stores it. + * + * A unit that is already walking reports `travelingFlag == 1` and its sampled cell + * is the reserved destination, so a second plan is refused instead of producing a + * path that does not start where the unit is standing. + */ +function planPath(st) { + local count = hexmap.unitCount(); + if (count <= 0) { st.statusText = "plan failed: no units"; return; } + if (!st.hasHover) { st.statusText = "plan failed: no cell under the cursor"; return; } + local sample = hexmap.unitSample(st.selectedId); + if (!sample.ok) { st.statusText = "plan failed: " + sample.status.summary; return; } + if (sample.value[6] != 0) { st.statusText = "plan failed: unit " + st.selectedId + " is travelling"; return; } + + local found = hexmap.findPath(sample.value[0], sample.value[1], st.hoverX, st.hoverZ); + if (!found.ok) { + clearPathPreview(st); + st.statusText = "path failed: " + found.status.summary; + return; + } + st.pathPreview = found.value; + st.pathDirty = true; + st.statusText = "path " + st.pathPreview.len() + " cells, " + pathTurns(st) + " turns [space] go"; +} + +/** Arrival turn of the planned path: the last entry's turn value. */ +function pathTurns(st) { + if (st.pathPreview.len() == 0) return 0; + return st.pathPreview[st.pathPreview.len() - 1][2]; +} + +/** Commits the planned path, or reports why there is nothing to commit. */ +function commitPath(st) { + if (st.pathPreview.len() == 0) { st.statusText = "move failed: no planned path (press G)"; return; } + local moved = hexmap.travelUnit(st.selectedId, st.pathPreview); + if (!moved.ok) { + st.statusText = "move failed: " + moved.status.summary; + return; + } + st.statusText = "unit " + st.selectedId + " travelling " + pathTurns(st) + " turns"; + clearPathPreview(st); +} + +/** Adds a unit on the hovered cell; the module reports every rejection reason. */ +function addUnitAtHover(st) { + if (!st.hasHover) { st.statusText = "add unit failed: no cell under the cursor"; return; } + if (hexmap.unitCount() >= MAX_UNITS) { + st.statusText = "add unit failed: the " + MAX_UNITS + " unit pool is full"; + return; + } + local added = hexmap.addUnit(st.hoverX, st.hoverZ, UNIT_TURN); + if (!added.ok) { st.statusText = "add unit failed: " + added.status.summary; return; } + st.selectedId = added.value; + st.statusText = "unit " + added.value + " added at (" + st.hoverX + ", " + st.hoverZ + ")"; +} + +/** Removes the selected unit and drops any preview that pointed at it. */ +function removeSelectedUnit(st) { + if (hexmap.unitCount() <= 0) { st.statusText = "remove unit failed: there are no units"; return; } + local removed = hexmap.removeUnit(st.selectedId); + if (!removed.ok) { st.statusText = "remove unit failed: " + removed.status.summary; return; } + st.statusText = "unit " + st.selectedId + " removed"; + clearPathPreview(st); +} + +/** + * Redraws the world-space path polyline when the plan changed. + * + * The line reuses the hex-cursor primitive recipe: a depth-ignoring polyline lifted + * `PATH_LIFT` above the cell centres. A one-cell plan (the unit is already there) + * has no segment to draw, so it hides the line instead. + */ +function updatePathPreview(st) { + if (st.pathLine == null || !st.pathDirty) return; + st.pathDirty = false; + if (st.pathPreview.len() < 2) { + st.pathLine.setVisible(false); + return; + } + local points = []; + foreach (cell in st.pathPreview) { + points.append(hexmap.cellPositionX(cell[0], cell[1])); + points.append(hexmap.cellPositionY(cell[0], cell[1]) + PATH_LIFT); + points.append(hexmap.cellPositionZ(cell[0], cell[1])); + } + local applied = st.pathLine.setPolyline(points, false); + if (!applied.ok) { + st.pathLine.setVisible(false); + return; + } + st.pathLine.setVisible(true); +} + +// --- save and load ---------------------------------------------------------- + +/** + * Lazily prepares the app-data save folder and returns the filesystem to use. + * + * The engine leaves the save identity to the game: without one `getSaveDirectory` + * is empty and every write is refused, so the identity is declared here exactly + * once (the same recipe `examples/terrain-editor` uses). + * + * @return The prepared filesystem, or null when the app-data folder is unusable. + */ +function prepareSaveFilesystem(st) { + if (st.saveFs == null) { + local fs = eve.Filesystem(); + if (fs.getIdentity() == "") fs.setIdentity("hex-terrain-3d", true); + st.saveFs = fs; + } + if (st.savePath == "") { + if (!st.saveFs.setupWriteDirectory()) return null; + st.savePath = st.saveFs.getSaveDirectory() + "/hex-terrain-3d-save.bin"; + } + return st.saveFs; +} + +/** + * Writes the module's whole payload (grid + units + explored flags) to disk. + * + * The payload is documented as byte-safe Squirrel string, so `writeText` stores it + * verbatim. Success is reported only after both the module call and the write + * reported success. + */ +function saveMapToDisk(st) { + local fs = prepareSaveFilesystem(st); + if (fs == null) { st.statusText = "save failed: the app-data save directory is not writable"; return; } + local saved = hexmap.saveMap(); + if (!saved.ok) { st.statusText = "save failed: " + saved.status.summary; return; } + if (!fs.writeText(st.savePath, saved.value)) { + st.statusText = "save failed: could not write " + st.savePath; + return; + } + st.statusText = "saved " + saved.value.len() + " bytes"; +} + +/** + * Re-syncs every script-side renderable after the module adopted a new grid. + * + * `loadMap` publishes the restored grid *before* it validates the restored units, + * so its Result can be a failure while the previous grid is already gone. The grid + * is therefore the only thing that matters here: the pool slots must be hidden + * (their meshes were released), the map size cache refreshed, every chunk marked + * dirty and the path/selection state dropped. The caller decides what to report. + */ +function resyncAfterLoad(st) { + hideAllRenderables(st); + hideUnitRenderables(st); + + local size = hexmap.cellCountX(); + for (local index = 0; index < MAP_SIZES.len(); index++) { + if (MAP_SIZES[index] == size) st.sizeIndex = index; + } + + clearPathPreview(st); + st.selectedId = 0; + st.selectedTraveling = false; + st.hasHover = false; + st.hoverX = -1; st.hoverZ = -1; + // The restored visibility is already rebuilt by `loadMap`; recomputing it from + // the restored units only guards the case where the unit restore failed and the + // counters were left behind. It runs before the rebuild so the fog meshes match. + hexmap.resetVisibility(); + hexmap.markAllChunksDirty(); + rebuildAllChunks(st); + hideUnusedRenderables(st); + + st.pivotX = (hexmap.cellPositionX(0, 0) + hexmap.cellPositionX(size - 1, hexmap.cellCountZ() - 1)) * 0.5; + st.pivotZ = (hexmap.cellPositionZ(0, 0) + hexmap.cellPositionZ(size - 1, hexmap.cellCountZ() - 1)) * 0.5; + clampPivot(st); + applyCamera(st); +} + +/** Reads the payload back and rebuilds the grid from it. */ +function loadMapFromDisk(st) { + local fs = prepareSaveFilesystem(st); + if (fs == null) { st.statusText = "load failed: the app-data save directory is unavailable"; return; } + local blob = fs.readText(st.savePath); + if (blob.len() == 0) { st.statusText = "load failed: no save at " + st.savePath; return; } + + local loaded = hexmap.loadMap(gfx, blob); + resyncAfterLoad(st); + if (!loaded.ok) { st.statusText = "load failed: " + loaded.status.summary; return; } + st.statusText = "loaded " + blob.len() + " bytes, " + hexmap.unitCount() + " unit(s)"; +} + +// --- HUD -------------------------------------------------------------------- + +/** Human-readable form of a boolean, so the HUD never prints a raw `true`/`false`. */ +function yesNo(value) { + return value ? "yes" : "no"; +} + +function modeName(mode) { + if (mode == MODE_ELEVATION) return "Elevation"; + if (mode == MODE_WATER) return "Water"; + if (mode == MODE_TERRAIN) return "Terrain"; + if (mode == MODE_RIVER) return "River"; + return "Road"; +} + +function buildHud(st) { + ui.beginBuild(); + ui.beginWindow("HexMap", "root"); + ui.text("EVEngine Hex Map", "title"); + ui.text("", "mode"); + ui.text("", "cell"); + ui.text("", "structure"); + ui.text("", "map"); + ui.text("", "stats"); + ui.text("", "fog"); + ui.text("", "unit"); + ui.text("", "path"); + ui.separator("sep"); + ui.text("[WASD]pan [Q/E]rotate [wheel]zoom", "help1"); + ui.text("[1-5]mode [/]brush radius [,/.]terrain", "help2"); + ui.text("[LMB]raise/paint/drag [RMB]lower/erase", "help3"); + ui.text("[N]new seed [F]map size [R]rebuild", "help4"); + ui.text("[tab]next unit [G]plan [space]go [esc]clear", "help5"); + ui.text("[T]add unit [Y]remove unit [J][K]explore", "help6"); + ui.text("[M]generated map [F5]save [F9]load", "help7"); + 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"); + // The `ui.text(..., label)` labels in `buildHud` are the ids used here, so the + // two lists must stay in the same order. + local hover = st.hasHover ? ("(" + st.hoverX + ", " + st.hoverZ + ")") : "(none)"; + ui.setText("mode", "mode: " + modeName(st.mode) + " brush: " + st.brushRadius + + " terrain: " + TERRAIN_NAMES[st.terrainType]); + if (st.hasHover) { + local elevation = hexmap.elevation(st.hoverX, st.hoverZ); + local water = hexmap.waterLevel(st.hoverX, st.hoverZ); + local terrain = hexmap.terrainType(st.hoverX, st.hoverZ); + local features = ""; + if (hexmap.hasRiver(st.hoverX, st.hoverZ)) features += " river"; + if (hexmap.hasRoad(st.hoverX, st.hoverZ)) features += " road"; + if (hexmap.isExplored(st.hoverX, st.hoverZ)) features += " explored"; else features += " unexplored"; + if (!hexmap.isExplorable(st.hoverX, st.hoverZ)) features += " not-explorable"; + if (hexmap.isCellVisible(st.hoverX, st.hoverZ)) features += " visible"; + ui.setText("cell", "cell " + hover + " elev " + elevation + " water " + water + + " " + TERRAIN_NAMES[terrain] + features); + ui.setText("structure", "structure walled=" + yesNo(hexmap.isWalled(st.hoverX, st.hoverZ)) + + " urban " + hexmap.urbanLevel(st.hoverX, st.hoverZ) + + " farm " + hexmap.farmLevel(st.hoverX, st.hoverZ) + + " plant " + hexmap.plantLevel(st.hoverX, st.hoverZ) + + " special " + hexmap.specialIndex(st.hoverX, st.hoverZ)); + } else { + ui.setText("cell", "cell " + hover); + ui.setText("structure", "structure (no cell under the cursor)"); + } + ui.setText("map", "size " + MAP_SIZES[st.sizeIndex] + " seed " + st.seed + " " + st.statusText); + ui.setText("stats", "surfaces rebuilt " + st.meshCount + " dirty " + hexmap.dirtyChunkCount() + + " unit id at hover " + (st.hasHover ? hexmap.unitIdAt(st.hoverX, st.hoverZ) : -1)); + ui.setText("fog", "fog: " + hexmap.visibleCellCount() + " of " + + (hexmap.cellCountX() * hexmap.cellCountZ()) + " cells visible" + + " ([J]/[K] toggle explored/explorable on the hovered cell)"); + local count = hexmap.unitCount(); + if (count <= 0 || st.selectedId < 0) { + ui.setText("unit", "unit none (" + count + " in the map, [T] adds one)"); + } else { + ui.setText("unit", "unit " + st.selectedId + " of " + count + " cell (" + + st.selectedCellX + ", " + st.selectedCellZ + ") " + + (st.selectedTraveling ? "travelling" : "idle")); + } + if (st.pathPreview.len() == 0) { + ui.setText("path", "path none ([G] plans from the selected unit to the hovered cell)"); + } else { + ui.setText("path", "path " + st.pathPreview.len() + " cells " + pathTurns(st) + + " turns ([space] travel, [esc] clear)"); + } +} + +// --- frame loop ------------------------------------------------------------- + +function handleKeys(st) { + if (key_just_pressed("1")) st.mode = MODE_ELEVATION; + if (key_just_pressed("2")) st.mode = MODE_WATER; + if (key_just_pressed("3")) st.mode = MODE_TERRAIN; + if (key_just_pressed("4")) st.mode = MODE_RIVER; + if (key_just_pressed("5")) st.mode = MODE_ROAD; + + if (key_just_pressed("]")) st.brushRadius = st.brushRadius + 1 > 6 ? 6 : st.brushRadius + 1; + if (key_just_pressed("[")) st.brushRadius = st.brushRadius - 1 < 0 ? 0 : st.brushRadius - 1; + if (key_just_pressed(".")) st.terrainType = (st.terrainType + 1) % TERRAIN_NAMES.len(); + if (key_just_pressed(",")) st.terrainType = (st.terrainType + TERRAIN_NAMES.len() - 1) % TERRAIN_NAMES.len(); + + if (key_just_pressed("r")) { + st.seed = procgen.randomSeed(); + buildMap(st); + } + if (key_just_pressed("f")) { + st.sizeIndex = (st.sizeIndex + 1) % MAP_SIZES.len(); + buildMap(st); + } + if (key_just_pressed("n")) { + hexmap.markAllChunksDirty(); + rebuildAllChunks(st); + } + // `M` replaces the hand-authored map with a freshly generated one at the size + // the `F` key selects. `R` stays what it always was: a new noise seed for the + // hand-authored map, not a new map. + if (key_just_pressed("m")) reseedProceduralMap(st); + + // --- units, fog and travel --- + if (key_just_pressed("tab", "Tab")) { + local count = hexmap.unitCount(); + if (count > 0) { + st.selectedId = (st.selectedId + 1) % count; + st.statusText = "selected unit " + st.selectedId + " of " + count; + } + } + if (key_just_pressed("g")) planPath(st); + if (key_just_pressed("space", "Space")) commitPath(st); + if (key_just_pressed("escape", "Escape")) { + if (st.pathPreview.len() > 0) st.statusText = "path cleared"; + clearPathPreview(st); + } + if (key_just_pressed("t")) addUnitAtHover(st); + if (key_just_pressed("y")) removeSelectedUnit(st); + if (key_just_pressed("j")) toggleHoverExplored(st); + if (key_just_pressed("k")) toggleHoverExplorable(st); + + // --- persistence --- + if (key_just_pressed("F5")) saveMapToDisk(st); + if (key_just_pressed("F9")) loadMapFromDisk(st); +} + +eve_init = function() { + if (hexMap == null) hexMap = createHexState(); + local st = hexMap; + st.camera = eve.Camera3D(); + st.camera.setFov(50.0); + st.camera.setAmbient(0.34, 0.38, 0.44); + st.camera.setActive(true); + + gfx.setBackgroundColor(0.075, 0.11, 0.17, 1.0); + gfx.setDirectionalLight(-0.55, 0.78, 0.32, 1.55, 1.38, 1.12); + + buildMap(st); + createUnitPool(st); + // Hex cursor: a closed polyline redrawn whenever the hovered cell changes. + local highlightResult = gfx.newPrimitivePolyline3D( + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], true, 1.0, 0.82, 0.25, 1.0, 3.5); + if (highlightResult.ok) { + st.highlight = highlightResult.value; + st.highlight.setDepthMode("ignore"); + st.highlight.setVisible(false); + } else { + print("hex map: hex cursor unavailable: " + highlightResult.status.summary + "\n"); + } + // Planned-path polyline: the same recipe, open instead of closed, redrawn only + // when the plan changed. Two points are the primitive's minimum. + local pathResult = gfx.newPrimitivePolyline3D( + [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], false, 0.35, 0.95, 1.0, 1.0, 4.0); + if (pathResult.ok) { + st.pathLine = pathResult.value; + st.pathLine.setDepthMode("ignore"); + st.pathLine.setVisible(false); + } else { + print("hex map: path preview unavailable: " + pathResult.status.summary + "\n"); + } + buildHud(st); + applyCamera(st); + print("hex map: WASD pan | Q/E rotate | wheel zoom | 1-5 mode | LMB/RMB edit | R new seed\n"); + print("hex map: tab next unit | G plan | space go | esc clear | T add | Y remove | F5 save | F9 load\n"); + print("hex map: M generated map (uses the size selected by F)\n"); +}; + +eve_update = function(dt) { + local st = hexMap; + if (st == null) return; + st.elapsed += dt; + st.frameCount += 1; + gfx.setCloudShadows(0.24, 6.0, st.elapsed, 0.35, 0.38, 0.56, 0.62); + + handleKeys(st); + updateCamera(st, dt); + updateHover(st); + updateBrush(st); + // Units first: their motion is what makes the fog dirty, and `advanceUnits` is + // the module's per-frame simulation step (it must run even with no units). + hexmap.advanceUnits(dt); + updateUnits(st); + // Only the chunks the module flagged (visibility or edits) are rebuilt here. + updateDirtyChunks(st); + updatePathPreview(st); + updateHighlight(st); + refreshHud(st); + + // Readback is enabled by the first call, so retry until the frame lands. + if (!st.screenshotSaved && st.frameCount > 90 && gfx.saveFramePng("hex-map.png")) + st.screenshotSaved = true; +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); + ui.beginFrameAndRender(); +}; diff --git a/examples/hex-terrain-3d/shaders/hex_map_feature.frag b/examples/hex-terrain-3d/shaders/hex_map_feature.frag new file mode 100644 index 00000000..b568df88 --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_feature.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_feature.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_feature.frag.spv new file mode 100644 index 00000000..7c348c77 Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_feature.frag.spv differ diff --git a/examples/hex-terrain-3d/shaders/hex_map_fog.frag b/examples/hex-terrain-3d/shaders/hex_map_fog.frag new file mode 100644 index 00000000..8a945baf --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_fog.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_fog.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_fog.frag.spv new file mode 100644 index 00000000..5044b81e Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_fog.frag.spv differ diff --git a/examples/hex-terrain-3d/shaders/hex_map_river.frag b/examples/hex-terrain-3d/shaders/hex_map_river.frag new file mode 100644 index 00000000..139ce4d4 --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_river.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_river.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_river.frag.spv new file mode 100644 index 00000000..386fc795 Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_river.frag.spv differ diff --git a/examples/hex-terrain-3d/shaders/hex_map_road.frag b/examples/hex-terrain-3d/shaders/hex_map_road.frag new file mode 100644 index 00000000..27e3aebb --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_road.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_road.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_road.frag.spv new file mode 100644 index 00000000..930fe3fc Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_road.frag.spv differ diff --git a/examples/hex-terrain-3d/shaders/hex_map_terrain.frag b/examples/hex-terrain-3d/shaders/hex_map_terrain.frag new file mode 100644 index 00000000..2a472e79 --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_terrain.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_terrain.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_terrain.frag.spv new file mode 100644 index 00000000..0ce9c707 Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_terrain.frag.spv differ diff --git a/examples/hex-terrain-3d/shaders/hex_map_water.frag b/examples/hex-terrain-3d/shaders/hex_map_water.frag new file mode 100644 index 00000000..60af6c52 --- /dev/null +++ b/examples/hex-terrain-3d/shaders/hex_map_water.frag @@ -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); +} diff --git a/examples/hex-terrain-3d/shaders/hex_map_water.frag.spv b/examples/hex-terrain-3d/shaders/hex_map_water.frag.spv new file mode 100644 index 00000000..327989f7 Binary files /dev/null and b/examples/hex-terrain-3d/shaders/hex_map_water.frag.spv differ diff --git a/examples/hex-terrain/config.nut b/examples/hex-terrain/config.nut new file mode 100644 index 00000000..a44a427e --- /dev/null +++ b/examples/hex-terrain/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1100 + height = 720 + title = "EVEngine Hex Terrain" + debug = false + hotReload = true +}; diff --git a/examples/housegen/config.nut b/examples/housegen/config.nut index 7b1e1f9f..b01c1e33 100644 --- a/examples/housegen/config.nut +++ b/examples/housegen/config.nut @@ -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 +}; diff --git a/examples/ink-arena/config.nut b/examples/ink-arena/config.nut index c51afadc..38771e7b 100644 --- a/examples/ink-arena/config.nut +++ b/examples/ink-arena/config.nut @@ -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 +}; diff --git a/examples/map-fog/main.nut b/examples/map-fog/main.nut index 858696e1..cfa76807 100644 --- a/examples/map-fog/main.nut +++ b/examples/map-fog/main.nut @@ -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); }; diff --git a/examples/material-editor/README.md b/examples/material-editor/README.md new file mode 100644 index 00000000..193b5abf --- /dev/null +++ b/examples/material-editor/README.md @@ -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. diff --git a/examples/material-editor/config.nut b/examples/material-editor/config.nut new file mode 100644 index 00000000..fa65e7da --- /dev/null +++ b/examples/material-editor/config.nut @@ -0,0 +1,7 @@ +config = { + title = "EVEngine Material Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, +}; diff --git a/examples/material-editor/main.nut b/examples/material-editor/main.nut new file mode 100644 index 00000000..7b2e291d --- /dev/null +++ b/examples/material-editor/main.nut @@ -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"); + } +}; diff --git a/examples/material-editor/material-editor.png b/examples/material-editor/material-editor.png new file mode 100644 index 00000000..8b2f05f7 Binary files /dev/null and b/examples/material-editor/material-editor.png differ diff --git a/examples/mesh-impact-lab/README.md b/examples/mesh-impact-lab/README.md new file mode 100644 index 00000000..e175f57d --- /dev/null +++ b/examples/mesh-impact-lab/README.md @@ -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/-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 秒仍然存活且日志中没有错误标记。 diff --git a/examples/mesh-modifier-lab/README.md b/examples/mesh-modifier-lab/README.md new file mode 100644 index 00000000..910cf7a1 --- /dev/null +++ b/examples/mesh-modifier-lab/README.md @@ -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/-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 秒仍然存活且日志中没有错误标记。 diff --git a/examples/model-converter/config.nut b/examples/model-converter/config.nut index a4c35737..0ee23f4f 100644 --- a/examples/model-converter/config.nut +++ b/examples/model-converter/config.nut @@ -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. diff --git a/examples/pcg-depth-of-field/README.md b/examples/pcg-depth-of-field/README.md new file mode 100644 index 00000000..67bd2458 --- /dev/null +++ b/examples/pcg-depth-of-field/README.md @@ -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/-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 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-depth-of-field/config.nut b/examples/pcg-depth-of-field/config.nut new file mode 100644 index 00000000..86921414 --- /dev/null +++ b/examples/pcg-depth-of-field/config.nut @@ -0,0 +1 @@ +config={width=960,height=640,title="Pcg Auto Depth of Field",debug=true,hotReload=true}; diff --git a/examples/pcg-depth-of-field/main.nut b/examples/pcg-depth-of-field/main.nut new file mode 100644 index 00000000..57b13160 --- /dev/null +++ b/examples/pcg-depth-of-field/main.nut @@ -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();}; diff --git a/examples/pcg-detail-overwrite/README.md b/examples/pcg-detail-overwrite/README.md new file mode 100644 index 00000000..b251b824 --- /dev/null +++ b/examples/pcg-detail-overwrite/README.md @@ -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/-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 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-detail-overwrite/config.nut b/examples/pcg-detail-overwrite/config.nut new file mode 100644 index 00000000..6ac26d28 --- /dev/null +++ b/examples/pcg-detail-overwrite/config.nut @@ -0,0 +1,6 @@ +config = { + title = "Pcg Terrain Detail Overwrite", + width = 960, + height = 640, + hotReload = true, +} diff --git a/examples/pcg-detail-overwrite/main.nut b/examples/pcg-detail-overwrite/main.nut new file mode 100644 index 00000000..cfb86dab --- /dev/null +++ b/examples/pcg-detail-overwrite/main.nut @@ -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-debug GAME=examples/pcg-layer-culling +``` + +## 验证内容 + +- `camera.setLayerCullDistance(8, 32.0)` 与 `camera.setShadowLayerCullDistance(8, 26.0)`; +- 地面用 layer 0,三根近景立柱与前塔用 layer 8,另有一座 `z = -70`、缩放 8×10×8 的 + 远塔同样在 layer 8 —— 它超出 32 的相机距离,必须被剔除; +- 相机放置在 `(0, 5, 14)`,看向 `(0, 0.8, -12)`,裁剪面 0.1–120.0。 + +成功标记:`PCG_LAYER_CULLING_READY layer=8 cameraDistance=32 shadowDistance=26 farTower=culled` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 相机层剔除距离设置、场景搭建、成功标记 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-layer-culling/config.nut b/examples/pcg-layer-culling/config.nut new file mode 100644 index 00000000..8e1727c2 --- /dev/null +++ b/examples/pcg-layer-culling/config.nut @@ -0,0 +1,7 @@ +config = { + width = 960, + height = 640, + title = "Pcg Layer Distance Culling", + debug = true, + hotReload = true, +}; diff --git a/examples/pcg-layer-culling/main.nut b/examples/pcg-layer-culling/main.nut new file mode 100644 index 00000000..2b972999 --- /dev/null +++ b/examples/pcg-layer-culling/main.nut @@ -0,0 +1,8 @@ +persist pcgCullCamera = null +persist pcgCullObjects = [] +function pcgCullObject(mesh,x,y,z,sx,sy,sz,r,g,b,layer){local o=eve.Renderable3D();o.setMesh(mesh);o.setPosition(x,y,z);o.setScale(sx,sy,sz);o.setTint(r,g,b,1.0);o.setRoughness(0.72);o.setLayer(layer);o.setCastShadow(true);pcgCullObjects.push(o);return o;} +eve_init=function(){ + gfx.setBackgroundColor(0.025,0.045,0.075,1.0);pcgCullCamera=eve.Camera3D();pcgCullCamera.setEye(0.0,5.0,14.0);pcgCullCamera.setTarget(0.0,0.8,-12.0);pcgCullCamera.setClipPlanes(0.1,120.0);pcgCullCamera.setAmbient(0.24,0.28,0.36);pcgCullCamera.setLayerCullDistance(8,32.0);pcgCullCamera.setShadowLayerCullDistance(8,26.0); + local cube=gfx.newMeshCube(1.0);pcgCullObject(cube,0.0,-1.0,-14.0,22.0,0.5,42.0,0.12,0.18,0.13,0);pcgCullObject(cube,-4.0,1.0,-7.0,2.4,4.0,2.4,0.18,0.68,0.38,8);pcgCullObject(cube,0.0,1.0,-13.0,2.4,4.0,2.4,0.18,0.48,0.82,8);pcgCullObject(cube,4.0,1.0,-16.0,2.4,4.0,2.4,0.82,0.48,0.16,8);pcgCullObject(cube,0.0,4.0,-70.0,8.0,10.0,8.0,1.0,0.05,0.05,8);print("PCG_LAYER_CULLING_READY layer=8 cameraDistance=32 shadowDistance=26 farTower=culled\n"); +}; +eve_update=function(dt){};eve_render=function(){gfx.clear();gfx.render3D();}; diff --git a/examples/pcg-location-system/README.md b/examples/pcg-location-system/README.md new file mode 100644 index 00000000..b07b5a2a --- /dev/null +++ b/examples/pcg-location-system/README.md @@ -0,0 +1,42 @@ +# PCG Location System(位置书签系统) + +验证 **位置书签**:`eve.LocationProfile` 保存"相机位姿 + 玩家位姿 + 控制器 + 场景名" +这样一个命名书签,之后按索引读回并把相机放到保存的位置上。用于关卡内的快速跳转、 +编辑器预览与自动化取景。 + +## 运行 + +```bash +cd examples/pcg-location-system +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-location-system +``` + +## 验证内容 + +- `eve.LocationBookmark`:`name = "Overlook"`、`controller = "FlyingCamera"`、 + `scene = "PcgLocationDemo"`,`setCamera(overlook)` / `setPlayer(player)` 分别绑定两个 + `eve.LocationPose`; +- `LocationProfile.addBookmark(bookmark)` 与 `loadBookmark(0)` 都必须返回 `ok`,且读回的 + 书签 `hasPlayer` 为真,否则 `assert` 失败; +- 相机的 `setEye` 直接取自读回的 `camera.x/y/z`; +- 读回 `getBookmarkController(0)` / `getBookmarkScene(0)`,确认控制器与场景名往返一致。 + +成功标记:`PCG_LOCATION_SYSTEM_READY bookmark=Overlook controller=FlyingCamera scene=PcgLocationDemo` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 书签构造、存取校验、相机取景、成功标记 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-location-system/config.nut b/examples/pcg-location-system/config.nut new file mode 100644 index 00000000..b11fb706 --- /dev/null +++ b/examples/pcg-location-system/config.nut @@ -0,0 +1,7 @@ +config = { + width = 960, + height = 640, + title = "Pcg Location System", + debug = true, + hotReload = true, +}; diff --git a/examples/pcg-location-system/main.nut b/examples/pcg-location-system/main.nut new file mode 100644 index 00000000..3b03b7c5 --- /dev/null +++ b/examples/pcg-location-system/main.nut @@ -0,0 +1,41 @@ +persist pcgLocationCamera = null +persist pcgLocationObjects = [] +persist pcgLocationProfile = null + +function addLandmark(mesh, x, y, z, sx, sy, sz, r, g, b) { + local object = eve.Renderable3D(); + object.setMesh(mesh); object.setPosition(x, y, z); object.setScale(sx, sy, sz); + object.setTint(r, g, b, 1.0); object.setRoughness(0.7); object.setCastShadow(true); + pcgLocationObjects.push(object); +} + +eve_init = function() { + gfx.setBackgroundColor(0.035, 0.065, 0.105, 1.0); + pcgLocationCamera = eve.Camera3D(); + pcgLocationCamera.setTarget(0.0, 1.5, -10.0); + pcgLocationCamera.setAmbient(0.28, 0.32, 0.38); + pcgLocationCamera.setClipPlanes(0.1, 100.0); + + pcgLocationProfile = eve.LocationProfile(); + local overlook = eve.LocationPose(); overlook.x = 13.0; overlook.y = 8.0; overlook.z = 15.0; + local player = eve.LocationPose(); player.x = 0.0; player.y = 1.0; player.z = -10.0; + local bookmark = eve.LocationBookmark(); bookmark.name = "Overlook"; + bookmark.controller = "FlyingCamera"; bookmark.scene = "PcgLocationDemo"; + bookmark.setCamera(overlook); bookmark.setPlayer(player); + assert(pcgLocationProfile.addBookmark(bookmark).ok); + local loaded = pcgLocationProfile.loadBookmark(0); + assert(loaded.ok && loaded.value.hasPlayer); + pcgLocationCamera.setEye(loaded.value.camera.x, loaded.value.camera.y, loaded.value.camera.z); + + local cube = gfx.newMeshCube(1.0); + addLandmark(cube, 0.0, -1.0, -10.0, 24.0, 0.5, 28.0, 0.10, 0.22, 0.14); + addLandmark(cube, -5.0, 1.5, -10.0, 2.4, 5.0, 2.4, 0.20, 0.75, 0.35); + addLandmark(cube, 0.0, 2.5, -10.0, 3.0, 7.0, 3.0, 0.20, 0.48, 0.95); + addLandmark(cube, 5.0, 1.0, -10.0, 2.0, 4.0, 2.0, 0.95, 0.48, 0.16); + print("PCG_LOCATION_SYSTEM_READY bookmark=Overlook controller=" + + pcgLocationProfile.getBookmarkController(0).value + " scene=" + + pcgLocationProfile.getBookmarkScene(0).value + "\n"); +}; + +eve_update = function(dt) {}; +eve_render = function() { gfx.clear(); gfx.render3D(); }; diff --git a/examples/pcg-runtime-orchestration/README.md b/examples/pcg-runtime-orchestration/README.md new file mode 100644 index 00000000..72bfb282 --- /dev/null +++ b/examples/pcg-runtime-orchestration/README.md @@ -0,0 +1,48 @@ +# PCG Runtime Orchestration(运行时编排) + +在**没有 GPU 场景**的情况下验证 Pcg 运行时三件套的数值契约:运行时盖章器 +(`PcgRuntimeStamper`)、生成进度(`PcgSpawnProgress`)与任务队列(`PcgTaskQueue`)。 +窗口只有 640×360、不可热重载,画面是纯 2D 矩形,因此这个示例基本等同于一个脚本级 +单元测试,但走的是完整的 `eve run` 启动路径。 + +## 运行 + +```bash +cd examples/pcg-runtime-orchestration +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-runtime-orchestration +``` + +## 验证内容 + +**运行时盖章**:源高度图 3×3(全部高度 1.0)经 `loadStamp` 载入,`execute` 以 +`(0, 0, 1.0, 1.0)` 盖到 5×5、初值 20.0 的目标高度图上;断言 `height(2, 2) == 6.0` +且 `getStatus() == 3`。 + +**生成进度**:`updateRule("Terrain", 10, 3, 4, 1)` 加 `updateRuleFraction(0.5)` 之后 +`getProgress()` 必须等于 0.35(容差 1e-4)。 + +**任务队列**:`add(0.25)` → `tick(0.25)` → `getReadyTaskId()` 必须等于 `add` 返回的 id → +`resolveReady(true)` 收尾。 + +另外打印宿主系统探测行 `PCG_HOST_SYSTEM limitFrame=... type=...`。示例保持正常帧循环, +以便 `scripts/smoke_examples.sh` 能验证它在启动后持续存活。 + +成功标记:`PCG_RUNTIME_ORCHESTRATION_PASS stamp=6 progress=0.35 queue=0` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 三项数值契约的构造、断言与成功标记;2D 矩形绘制 | +| `config.nut` | 引擎 `config`:640×360、`hotReload = false` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-runtime-orchestration/config.nut b/examples/pcg-runtime-orchestration/config.nut new file mode 100644 index 00000000..d456f2b6 --- /dev/null +++ b/examples/pcg-runtime-orchestration/config.nut @@ -0,0 +1 @@ +config = { title = "Pcg Runtime Orchestration", width = 640, height = 360, hotReload = false } diff --git a/examples/pcg-runtime-orchestration/main.nut b/examples/pcg-runtime-orchestration/main.nut new file mode 100644 index 00000000..00362d1e --- /dev/null +++ b/examples/pcg-runtime-orchestration/main.nut @@ -0,0 +1,51 @@ +local runtimeStamp = null; +local runtimeSource = null; +local runtimeTarget = null; +local spawnProgress = null; +local taskQueue = null; + +function checked(result, label) { + if (!result.ok) throw "pcg-runtime-orchestration: " + label + " failed"; + return result; +} + +eve_init = function() { + local sourceResult = procgen.newHeightmap(3, 3); + local targetResult = procgen.newHeightmap(5, 5); + checked(sourceResult, "source heightmap"); + checked(targetResult, "target heightmap"); + runtimeSource = sourceResult.value; + runtimeTarget = targetResult.value; + for (local y = 0; y < 3; ++y) for (local x = 0; x < 3; ++x) runtimeSource.setHeight(x, y, 1.0); + for (local y = 0; y < 5; ++y) for (local x = 0; x < 5; ++x) runtimeTarget.setHeight(x, y, 20.0); + + runtimeStamp = eve.PcgRuntimeStamper(); + checked(runtimeStamp.configure("Pcg\\Stamps\\Runtime", true, true), "configure"); + checked(runtimeStamp.loadStamp(runtimeSource, "Runtime"), "load"); + checked(runtimeStamp.execute(runtimeTarget, 0.0, 0.0, 1.0, 1.0), "stamp"); + if (runtimeTarget.height(2, 2) != 6.0 || runtimeStamp.getStatus() != 3) throw "stamp mismatch"; + + spawnProgress = eve.PcgSpawnProgress(); + checked(spawnProgress.updateRule("Terrain", 10, 3, 4, 1), "progress rule"); + checked(spawnProgress.updateRuleFraction(0.5), "progress fraction"); + if (abs(spawnProgress.getProgress() - 0.35) > 0.0001) throw "progress mismatch"; + + taskQueue = eve.PcgTaskQueue(); + local addResult = taskQueue.add(0.25); + checked(addResult, "task add"); + checked(taskQueue.tick(0.25), "task tick"); + if (taskQueue.getReadyTaskId() != addResult.value) throw "task ID mismatch"; + checked(taskQueue.resolveReady(true), "task resolve"); + + gfx.setBackgroundColor(0.025, 0.055, 0.09, 1.0); + print("PCG_HOST_SYSTEM limitFrame="+("limitFrame" in system)+" type="+typeof system+"\n"); + print("PCG_RUNTIME_ORCHESTRATION_PASS stamp=6 progress=0.35 queue=0\n"); +}; + +eve_update = function(dt) {}; +eve_render = function() { + gfx.clear(); + gfx.drawSolidRect(64.0, 80.0, 512.0, 200.0, 0.12, 0.48, 0.28, 1.0); + gfx.drawSolidRect(88.0, 105.0, 464.0, 150.0, 0.04, 0.15, 0.10, 1.0); + gfx.drawSolidRect(88.0, 215.0, 162.0, 18.0, 0.25, 0.82, 0.42, 1.0); +}; diff --git a/examples/pcg-scene-player-culling/README.md b/examples/pcg-scene-player-culling/README.md new file mode 100644 index 00000000..5934c089 --- /dev/null +++ b/examples/pcg-scene-player-culling/README.md @@ -0,0 +1,44 @@ +# PcgScenePlayer Terrain Culling(场景地形剔除) + +验证 `eve.Scene` 上的 **Pcg 地形剔除接口**:给场景节点设置包围盒并打上 `terrain` 标签, +然后用当前相机与画布尺寸调用 `applyPcgTerrainCullingAt`,只有视锥外的地形节点被隐藏, +**没有标签的节点必须保持可见**。 + +## 运行 + +```bash +cd examples/pcg-scene-player-culling +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-scene-player-culling +``` + +## 验证内容 + +- `eve.Scene` 声明式建树:`beginBuild` / `beginNode` / `setBuildPosition` / + `setBuildScale` / `end`,再 `mountBuildAs("pcg-scene-player")`; +- 三个节点:`terrain-visible`(视锥内)、`terrain-culled`(`x = 48`,视锥外)、 + `landmark-control`(无 `terrain` 标签的对照组); +- 通过 `getNodeRef(id).setBounds(-0.5, -0.5, -0.5, 0.5, 0.5, 0.5)` 给地形节点设包围盒, + 再用 `addTag("terrain")` 打标签;三个节点各自 `linkRenderable3D` 绑定可见物体; +- `applyPcgTerrainCullingAt("pcg-scene-player", camera, 960.0, 640.0, "terrain")` 返回本次 + 改动的节点数,`!result.ok` 时 `throw`; +- 读回 `getNodeVisible(...)`:视锥外地形必须被剔除,视锥内地形与无标签对照组必须可见。 + +成功标记:`PCG_SCENE_PLAYER_CULLING_READY changed=... visible=... culled=... control=...` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 场景建树、包围盒与标签、剔除调用与可见性断言 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-scene-player-culling/config.nut b/examples/pcg-scene-player-culling/config.nut new file mode 100644 index 00000000..f5a886d2 --- /dev/null +++ b/examples/pcg-scene-player-culling/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 960, + height = 640, + title = "PcgScenePlayer Terrain Culling", + debug = true, + hotReload = true, +}; diff --git a/examples/pcg-scene-player-culling/main.nut b/examples/pcg-scene-player-culling/main.nut new file mode 100644 index 00000000..5de19515 --- /dev/null +++ b/examples/pcg-scene-player-culling/main.nut @@ -0,0 +1,83 @@ +persist cullingScene = null +persist cullingCamera = null +persist cullingObjects = [] + +function makeBlock(mesh, r, g, b) { + local object = eve.Renderable3D() + object.setMesh(mesh) + object.setTint(r, g, b, 1.0) + object.setRoughness(0.78) + object.setCastShadow(true) + cullingObjects.push(object) + return object +} + +function configureBoundedNode(id, tag) { + local ref = cullingScene.getNodeRef(id) + if (ref == null) throw "missing scene node: " + id + ref.setBounds(-0.5, -0.5, -0.5, 0.5, 0.5, 0.5) + if (tag != "") ref.addTag(tag) +} + +eve_init = function() { + gfx.setBackgroundColor(0.025, 0.055, 0.085, 1.0) + gfx.setDirectionalLight(-0.45, -1.0, -0.25, 2.4, 2.25, 1.9) + + cullingCamera = eve.Camera3D() + cullingCamera.setEye(0.0, 6.0, 14.0) + cullingCamera.setTarget(0.0, 0.5, -12.0) + cullingCamera.setClipPlanes(0.1, 100.0) + cullingCamera.setAmbient(0.23, 0.27, 0.34) + cullingCamera.setActive(true) + + cullingScene = eve.Scene() + cullingScene.beginBuild() + cullingScene.beginNode("root", "Pcg terrain roots") + cullingScene.setBuildSpace("3d") + cullingScene.beginNode("terrain-visible", "Visible terrain tile") + cullingScene.setBuildPosition(0.0, -1.0, -12.0) + cullingScene.setBuildScale(18.0, 0.8, 18.0) + cullingScene.end() + cullingScene.beginNode("terrain-culled", "Off-frustum terrain tile") + cullingScene.setBuildPosition(48.0, 1.0, -14.0) + cullingScene.setBuildScale(5.0, 5.0, 5.0) + cullingScene.end() + cullingScene.beginNode("landmark-control", "Untagged landmark") + cullingScene.setBuildPosition(4.5, 1.4, -10.0) + cullingScene.setBuildScale(2.0, 4.8, 2.0) + cullingScene.end() + cullingScene.end() + if (!cullingScene.mountBuildAs("pcg-scene-player")) throw "scene mount failed" + + configureBoundedNode("terrain-visible", "terrain") + configureBoundedNode("terrain-culled", "terrain") + configureBoundedNode("landmark-control", "") + + local cube = gfx.newMeshCube(1.0) + local terrain = makeBlock(cube, 0.16, 0.52, 0.25) + local outside = makeBlock(cube, 0.95, 0.08, 0.15) + local landmark = makeBlock(cube, 0.92, 0.66, 0.18) + if (!cullingScene.linkRenderable3D("terrain-visible", terrain)) throw "terrain link failed" + if (!cullingScene.linkRenderable3D("terrain-culled", outside)) throw "outside link failed" + if (!cullingScene.linkRenderable3D("landmark-control", landmark)) throw "landmark link failed" + + cullingScene.updateTransforms() + local result = cullingScene.applyPcgTerrainCullingAt( + "pcg-scene-player", cullingCamera, 960.0, 640.0, "terrain") + if (!result.ok) throw result.status.summary + local changed = result.value + cullingScene.updateTransforms() + print("PCG_SCENE_PLAYER_CULLING_READY changed=" + changed + + " visible=" + cullingScene.getNodeVisible("terrain-visible") + + " culled=" + cullingScene.getNodeVisible("terrain-culled") + + " control=" + cullingScene.getNodeVisible("landmark-control") + "\n") +} + +eve_update = function(dt) { + cullingScene.updateTransforms() +} + +eve_render = function() { + gfx.clear() + gfx.render3D() +} diff --git a/examples/pcg-snow-wind/README.md b/examples/pcg-snow-wind/README.md new file mode 100644 index 00000000..7fa86d03 --- /dev/null +++ b/examples/pcg-snow-wind/README.md @@ -0,0 +1,40 @@ +# PCG Snow Wind(雪的全局风场) + +验证天气系统里 **雪的风场** 是真正的世界空间向量而非屏幕偏移:脚本用 +`setSnowWind(4.5, -2.8, 1.5)` 写入三分量风,再通过 `getSnowWindX/Y/Z` 读回校验, +并确认它作用于整个世界(`world=true`)而不是跟随相机。 + +## 运行 + +```bash +cd examples/pcg-snow-wind +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-snow-wind +``` + +## 验证内容 + +- `eve.Weather` 使用预置 `"snow"`、强度 1.0,随后 `init(gfx)`; +- `setSnowWind(4.5, -2.8, 1.5)` 必须返回 `ok`,否则 `throw result.status.summary`; +- 读回 `getSnowWindX()` / `getSnowWindY()` / `getSnowWindZ()`,确认与写入一致; +- 场景是三块立方体(地面 / 两根立柱)加一盏平行光,专门给雪提供可读的背景与光照; +- 每帧 `snowWeather.update(dt, gfx)`。 + +成功标记:`PCG_SNOW_WIND_READY x=4.5 y=-2.8 z=1.5 world=true` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 天气与风场设置、场景搭建、成功标记 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-snow-wind/config.nut b/examples/pcg-snow-wind/config.nut new file mode 100644 index 00000000..57408c8c --- /dev/null +++ b/examples/pcg-snow-wind/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 960, + height = 640, + title = "Pcg Snow Wind", + debug = true, + hotReload = true, +}; diff --git a/examples/pcg-snow-wind/main.nut b/examples/pcg-snow-wind/main.nut new file mode 100644 index 00000000..e99813e8 --- /dev/null +++ b/examples/pcg-snow-wind/main.nut @@ -0,0 +1,20 @@ +persist snowWeather = null +persist snowCamera = null +persist snowScene = [] +function snowBlock(mesh,x,y,z,sx,sy,sz,r,g,b){local o=eve.Renderable3D();o.setMesh(mesh);o.setPosition(x,y,z);o.setScale(sx,sy,sz);o.setTint(r,g,b,1.0);o.setRoughness(0.84);snowScene.push(o);} +eve_init=function(){ + gfx.setBackgroundColor(0.035,0.055,0.09,1.0); + snowCamera=eve.Camera3D();snowCamera.setEye(0.0,5.5,13.0);snowCamera.setTarget(0.0,1.5,-10.0); + snowCamera.setAmbient(0.24,0.27,0.34);snowCamera.setActive(true); + gfx.setDirectionalLight(-0.5,-1.0,-0.2,2.2,2.3,2.6); + local cube=gfx.newMeshCube(1.0); + snowBlock(cube,0.0,-1.0,-11.0,24.0,0.5,30.0,0.18,0.22,0.25); + snowBlock(cube,-4.0,1.0,-9.0,2.0,4.0,2.0,0.22,0.32,0.38); + snowBlock(cube,3.5,1.5,-13.0,2.5,5.0,2.5,0.30,0.24,0.20); + snowWeather=eve.Weather();snowWeather.setPreset("snow");snowWeather.setIntensity(1.0); + local result=snowWeather.setSnowWind(4.5,-2.8,1.5);if(!result.ok)throw result.status.summary; + snowWeather.init(gfx); + print("PCG_SNOW_WIND_READY x="+snowWeather.getSnowWindX()+" y="+snowWeather.getSnowWindY()+" z="+snowWeather.getSnowWindZ()+" world=true"); +}; +eve_update=function(dt){snowWeather.update(dt,gfx);}; +eve_render=function(){gfx.clear();gfx.render3D();}; diff --git a/examples/pcg-thunder-strike/README.md b/examples/pcg-thunder-strike/README.md new file mode 100644 index 00000000..6add21f1 --- /dev/null +++ b/examples/pcg-thunder-strike/README.md @@ -0,0 +1,43 @@ +# PCG Thunder Strike(雷击事件) + +验证**雷击**这条事件链路:`eve.triggerThunderStrike` 用一个确定性 seed 在指定世界 +坐标触发雷击,返回落点、作用半径、播放的音频片段索引与强度;脚本把一盏点光源放到 +落点上,并用 `eve.advanceThunderStrike` 让它随雷击状态逐帧衰减。天气系统同时切到 +`storm` 预置并触发一次它的内部雷击。 + +## 运行 + +```bash +cd examples/pcg-thunder-strike +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-thunder-strike +``` + +## 验证内容 + +- `eve.ThunderStrikeSettings`:`intensity = 2.5`、`radius = 420.0`、`volume = 0.8`、 + `audioClipCount = 4`; +- `eve.triggerThunderStrike(state, settings, 0.0, 0.0, -8.0, 20260912)` 必须 + `assert(strike.ok)`;落点与半径写进 `eve.Light3D`(点光源,颜色 0.40/0.58/1.0,强度 2.5); +- `eve.Weather` 预置 `"storm"`、强度 0.72,并调用 `strike()`; +- 每帧 `advanceThunderStrike(state, dt)`,光源 `setEnabled(state.playing)`、强度跟随 + `state.intensity`。 + +成功标记:`PCG_THUNDER_STRIKE_READY audioIndex=... radius=... seed=20260912` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 雷击触发、光源绑定、天气预置、逐帧推进与成功标记 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-thunder-strike/config.nut b/examples/pcg-thunder-strike/config.nut new file mode 100644 index 00000000..e461e63f --- /dev/null +++ b/examples/pcg-thunder-strike/config.nut @@ -0,0 +1 @@ +config = { width = 960, height = 640, title = "Pcg Thunder Strike", debug = true, hotReload = true }; diff --git a/examples/pcg-thunder-strike/main.nut b/examples/pcg-thunder-strike/main.nut new file mode 100644 index 00000000..6a82db90 --- /dev/null +++ b/examples/pcg-thunder-strike/main.nut @@ -0,0 +1,37 @@ +persist pcgThunderCamera = null +persist pcgThunderLight = null +persist pcgThunderState = null +persist pcgThunderObjects = [] +persist pcgThunderWeather = null + +function thunderCube(mesh,x,y,z,sx,sy,sz,r,g,b) { + local object=eve.Renderable3D(); object.setMesh(mesh); object.setPosition(x,y,z); + object.setScale(sx,sy,sz); object.setTint(r,g,b,1.0); object.setRoughness(0.85); + pcgThunderObjects.push(object); +} + +eve_init=function() { + gfx.setBackgroundColor(0.018,0.025,0.055,1.0); + pcgThunderCamera=eve.Camera3D(); pcgThunderCamera.setEye(12.0,8.0,18.0); + pcgThunderCamera.setTarget(0.0,1.0,-8.0); pcgThunderCamera.setAmbient(0.06,0.07,0.11); + pcgThunderLight=eve.Light3D(); pcgThunderLight.setType("point"); + pcgThunderLight.setColor(0.40,0.58,1.0,2.5); + pcgThunderState=eve.ThunderStrikeState(); local settings=eve.ThunderStrikeSettings(); + settings.intensity=2.5; settings.radius=420.0; settings.volume=0.8; settings.audioClipCount=4; + local strike=eve.triggerThunderStrike(pcgThunderState,settings,0.0,0.0,-8.0,20260912); + assert(strike.ok); pcgThunderLight.setPosition(strike.value.x,strike.value.y,strike.value.z); + pcgThunderLight.setRadius(strike.value.radius); + pcgThunderWeather=eve.Weather(); pcgThunderWeather.init(gfx); pcgThunderWeather.setPreset("storm"); + pcgThunderWeather.setIntensity(0.72); pcgThunderWeather.strike(); + local cube=gfx.newMeshCube(1.0); thunderCube(cube,0.0,-1.0,-8.0,24.0,0.5,28.0,0.08,0.11,0.16); + thunderCube(cube,-4.0,1.0,-8.0,2.5,4.0,2.5,0.15,0.28,0.38); + thunderCube(cube,2.5,2.0,-10.0,3.0,6.0,3.0,0.18,0.24,0.34); + print("PCG_THUNDER_STRIKE_READY audioIndex="+strike.value.audioClipIndex+ + " radius="+strike.value.radius+" seed=20260912\n"); +}; +eve_update=function(dt) { + pcgThunderWeather.update(dt,gfx); eve.advanceThunderStrike(pcgThunderState,dt); + pcgThunderLight.setEnabled(pcgThunderState.playing); + pcgThunderLight.setColor(0.40,0.58,1.0,pcgThunderState.intensity); +}; +eve_render=function(){gfx.clear();gfx.render3D();}; diff --git a/examples/pcg-ui-scaler/README.md b/examples/pcg-ui-scaler/README.md new file mode 100644 index 00000000..6499cc0f --- /dev/null +++ b/examples/pcg-ui-scaler/README.md @@ -0,0 +1,44 @@ +# PCG Parent Scaler(父级缩放 / 画布高度适配) + +验证 **UI 面板按父画布高度自适应并夹紧到上限** 的求值接口:给定画布高度 640、目标 +数量 2 与最大高度 500,`eve.evaluateParentScaler` 必须给出 `applyHeight = true` 且 +`height = 500`(被上限夹紧,而不是取 640)。求值结果随后真的用于搭建一个 UI 面板, +所以这是"求值 + 落地"两条都覆盖的示例。 + +## 运行 + +```bash +cd examples/pcg-ui-scaler +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-ui-scaler +``` + +## 验证内容 + +- `eve.ParentScalerSettings`:`partScreen = true`、`maxHeight = 500.0`; +- `eve.ParentScalerInput`:`hasCanvas = true`、`targetCount = 2`、`canvasHeight = 640.0`; +- `eve.evaluateParentScaler(state, output, settings, input)` 必须返回 `ok`,且 + `output.applyHeight` 为真、`output.height == 500.0`,否则 `throw`; +- 用 `ui.*` 搭建暗色主题面板复现这组数值(画布 640 px、上限 500 px、2 个调用方持有的 + 目标、进度 500/640),`mountBuildAs("pcg-parent-scaler")` 后设置宿主尺寸 620×500、 + 位置 (170, 70),并禁止拖动与缩放; +- 每帧 `ui.beginFrameAndRender()`。 + +成功标记:`PCG_PARENT_SCALER_READY canvas=640 mode=part targets=2 height=500` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 缩放求值与断言、UI 面板搭建、宿主尺寸设置、成功标记 | +| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且错误标记不出现在日志中。 diff --git a/examples/pcg-ui-scaler/config.nut b/examples/pcg-ui-scaler/config.nut new file mode 100644 index 00000000..1f05b586 --- /dev/null +++ b/examples/pcg-ui-scaler/config.nut @@ -0,0 +1,7 @@ +config = { + title = "Pcg Parent Scaler", + width = 960, + height = 640, + debug = true, + hotReload = true, +}; diff --git a/examples/pcg-ui-scaler/main.nut b/examples/pcg-ui-scaler/main.nut new file mode 100644 index 00000000..ad071ce2 --- /dev/null +++ b/examples/pcg-ui-scaler/main.nut @@ -0,0 +1,27 @@ +local retained=[]; +function retain(v){if(v==null)throw "pcg-ui-scaler: null object";retained.append(v);return v;} +function requireResult(r,name){if(!r.ok)throw "pcg-ui-scaler: "+name+" failed";return r.value;} + +eve_init=function(){ + local settings=retain(eve.ParentScalerSettings());settings.partScreen=true;settings.maxHeight=500.0; + local input=retain(eve.ParentScalerInput());input.hasCanvas=true;input.targetCount=2;input.canvasHeight=640.0; + local state=retain(eve.ParentScalerState());local output=retain(eve.ParentScalerOutput()); + requireResult(eve.evaluateParentScaler(state,output,settings,input),"evaluate"); + if(!output.applyHeight||output.height!=500.0)throw "pcg-ui-scaler: incorrect clamped height"; + + ui.setTheme("dark");ui.beginBuild();ui.beginWindow("Pcg Parent Scaler", "root"); + ui.sectionHeader("PART SCREEN MODE", "mode"); + ui.text("Canvas height", "canvas-label");ui.sameLine("canvas-line");ui.badge("640 px", "canvas-value"); + ui.text("Maximum height", "maximum-label");ui.sameLine("maximum-line");ui.badge("500 px", "maximum-value"); + ui.separator("result-separator"); + ui.textWrapped("The target panel follows canvas height and clamps to Pcg's configured maximum.",520.0,"description"); + ui.progress(0.78125,"height-progress","500 / 640"); + ui.beginCard("result-card");ui.sectionHeader("APPLIED OUTPUT", "result-title"); + ui.text("Two caller-owned Rect targets", "target-count");ui.badge("HEIGHT 500", "result-height");ui.end(); + ui.end();ui.mountBuildAs("pcg-parent-scaler");ui.setHostSize(620.0,output.height); + ui.setHostPos(170.0,70.0,0.0,0.0);ui.setHostMovable(false);ui.setHostResizable(false); + print("PCG_PARENT_SCALER_READY canvas=640 mode=part targets=2 height="+output.height+"\n"); +}; + +eve_update=function(dt){}; +eve_render=function(){gfx.clear();ui.beginFrameAndRender();}; diff --git a/examples/pcg-underwater/README.md b/examples/pcg-underwater/README.md new file mode 100644 index 00000000..60022b8d --- /dev/null +++ b/examples/pcg-underwater/README.md @@ -0,0 +1,61 @@ +# PCG Underwater Effects(水下效果全链路) + +本仓库里覆盖最完整的一个 PCG 示例:把**进入 / 离开水面的整套表现**串成一条链路—— +深度雾与颜色梯度、时间驱动的后期曝光与色彩曲线、水焦散(caustic)投影、水下材质替换、 +三路音频(入水 / 出水 / 循环环境音)、粒子生命周期、触发体、原点平移与地形重力跟随。 +所有采样都是脚本显式驱动的,因此无头运行时结果确定。 + +## 运行 + +```bash +cd examples/pcg-underwater +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/pcg-underwater +``` + +## 验证内容 + +- **状态推进**:`eve.advanceWaterUnderwaterEffects(state, output, settings, input, depthGradient, timeGradient, postExposureCurve, postColorGradient)` + 先在水面上"播种"(`cameraY = 6.0`,`timeOfDay = 0.5`),再把相机压到 `4.21` 完成入水; + 断言 `output.entered`、`output.loopAudio` 与 `causticFrame == 3`; +- **雾**:`WaterUnderwaterSettings`(`causticTextureCount = 16`、`fogDepth = 20`、 + `fogDensity = 0.052`、`fogDistance = 42`)配合三段深度雾梯度与两段昼夜雾梯度 + (`UnderwaterColorGradient.addStop`),经 `eve.applyWaterUnderwaterFog` 写进体积雾; +- **后期**:`UnderwaterScalarCurve` 的曝光关键帧与后期色彩梯度经 + `eve.applyWaterUnderwaterPostFx` 应用; +- **材质**:`WaterSurfaceMaterialSnapshot` 经 `eve.applyWaterUnderwaterMaterial` 替换水下 + 物体着色器/颜色,断言读回的 `getTintR()` 为 0.72; +- **音频**:`eve.applyUnderwaterAudio(入水, 出水, 循环环境音, ...)` 之后断言环境音 + `isLooping()` 且 `isPlaying()`;音频文件取自本目录的三个 wav; +- **水焦散**:`gfx.newTextureFromFile("caustic-cookie.png")` 作为方向光 cookie, + 在渲染阶段 `fog.projectDirectionalCookie(...)` 投影到深度图; +- **其它**:粒子发射器生命周期(水面 VFX 暂停、地面粒子被剔除、原点平移后的粒子数)、 + 触发体恢复、地形重力激活、跟随水面的位置与着色器、地平线可见性。 + +成功标记: + +``` +PCG_UNDERWATER_READY depth=... caustic=... fog=... transition=... audio=looping +particles=active surfaceVfx=paused trigger=restored groundParticles=culled +originParticles=... terrainGravity=activated followWater=applied material=mainLight horizon=visible +``` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 入水/出水状态推进、雾与后期与材质 provider、音频、粒子、渲染阶段焦散投影 | +| `config.nut` | 引擎 `config`:960×640、`hotReload = true` | +| `caustic-cookie.png` | 方向光水焦散 cookie | +| `submerge-down.wav` / `submerge-up.wav` | 入水 / 出水音效 | +| `underwater-loop.wav` | 水下循环环境音 | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/pcg-underwater/caustic-cookie.png b/examples/pcg-underwater/caustic-cookie.png new file mode 100644 index 00000000..13997916 Binary files /dev/null and b/examples/pcg-underwater/caustic-cookie.png differ diff --git a/examples/pcg-underwater/config.nut b/examples/pcg-underwater/config.nut new file mode 100644 index 00000000..339838ab --- /dev/null +++ b/examples/pcg-underwater/config.nut @@ -0,0 +1,6 @@ +config = { + title = "Pcg Underwater Effects", + width = 960, + height = 640, + hotReload = true, +} diff --git a/examples/pcg-underwater/main.nut b/examples/pcg-underwater/main.nut new file mode 100644 index 00000000..a60adbd3 --- /dev/null +++ b/examples/pcg-underwater/main.nut @@ -0,0 +1,206 @@ +local retained=[]; +local camera=null; +local fog=null; +local water=null; +local waterEntity=null; +local underwaterMaterialEntity=null; +local underwaterState=null; +local underwaterOutput=null; +local underwaterSettings=null; +local underwaterInput=null; +local depthGradient=null; +local timeGradient=null; +local surfaceFog=null; +local surfacePostFx=null; +local surfaceMaterial=null; +local postExposureCurve=null; +local postColorGradient=null; +local causticTexture=null; +local submergeDownSource=null; +local submergeUpSource=null; +local underwaterAmbienceSource=null; +local particleModule=null; +local underwaterParticleEmitter=null; +local transitionParticleEmitter=null; +local surfaceVfxEmitter=null; +local groundParticleEmitter=null; +local horizonEntity=null; +local triggerWorld=null; +local disableTriggerState=null; +local originShiftedParticles=0; +local terrainGravityActivated=false; +local followWaterApplied=false; +local seconds=0.0; + +function retain(v){if(v==null)throw "pcg-underwater: null resource";retained.append(v);return v;} +function requireResult(r,name){if(!r.ok)throw "pcg-underwater: "+name+" failed";return r.value;} +function object(mesh,x,y,z,sx,sy,sz,r,g,b){ + local e=retain(eve.Renderable3D());e.setMesh(mesh);e.setPosition(x,y,z);e.setScale(sx,sy,sz); + e.setTint(r,g,b,1.0);e.setRoughness(0.82);e.setCastShadow(true);return e; +} +function buildCausticCookie(){ + causticTexture=retain(gfx.newTextureFromFile("caustic-cookie.png")); +} +function setupUnderwaterAudio(){ + local sound=retain(eve.Sound());local audio=retain(eve.Audio()); + local downPcm=retain(sound.newSoundDataFromFile("submerge-down.wav")); + local upPcm=retain(sound.newSoundDataFromFile("submerge-up.wav")); + local ambiencePcm=retain(sound.newSoundDataFromFile("underwater-loop.wav")); + submergeDownSource=retain(audio.newSource(downPcm));submergeDownSource.setRelative(true); + submergeUpSource=retain(audio.newSource(upPcm));submergeUpSource.setRelative(true); + underwaterAmbienceSource=retain(audio.newSource(ambiencePcm));underwaterAmbienceSource.setRelative(true); + requireResult(eve.applyUnderwaterAudio(submergeDownSource,submergeUpSource,underwaterAmbienceSource, + underwaterOutput.playSubmergeDown,underwaterOutput.playSubmergeUp, + underwaterOutput.loopAudio,underwaterSettings.playbackVolume),"audio provider"); + if(!underwaterAmbienceSource.isLooping()||!underwaterAmbienceSource.isPlaying()) + throw "pcg-underwater: ambience source mismatch"; +} +function setupUnderwaterVisualLifecycle(){ + particleModule=retain(eve.Particles()); + underwaterParticleEmitter=retain(particleModule.newEmitter(256)); + underwaterParticleEmitter.setPosition(480.0,520.0);underwaterParticleEmitter.setEmissionArea("rectangle",400.0,35.0); + underwaterParticleEmitter.setEmissionRate(18.0);underwaterParticleEmitter.setParticleLifetime(2.0,4.5); + underwaterParticleEmitter.setSpeed(18.0,42.0);underwaterParticleEmitter.setDirection(-1.5708); + underwaterParticleEmitter.setSpread(0.32);underwaterParticleEmitter.setParticleSize(3.0,3.0); + underwaterParticleEmitter.setColorStart(0.55,0.88,1.0,0.55);underwaterParticleEmitter.setColorEnd(0.35,0.72,1.0,0.0); + transitionParticleEmitter=retain(particleModule.newEmitter(96)); + transitionParticleEmitter.setPosition(480.0,320.0);transitionParticleEmitter.setEmissionRate(90.0); + transitionParticleEmitter.setEmitterLifetime(0.35);transitionParticleEmitter.setParticleLifetime(0.25,0.7); + transitionParticleEmitter.setSpeed(45.0,110.0);transitionParticleEmitter.setSpread(6.283); + transitionParticleEmitter.setParticleSize(5.0,5.0);transitionParticleEmitter.setColorStart(0.65,0.92,1.0,0.7); + transitionParticleEmitter.setColorEnd(0.25,0.62,0.95,0.0); + surfaceVfxEmitter=retain(particleModule.newEmitter(96)); + surfaceVfxEmitter.setPosition(480.0,40.0);surfaceVfxEmitter.setEmissionArea("rectangle",900.0,20.0); + surfaceVfxEmitter.setEmissionRate(30.0);surfaceVfxEmitter.setParticleLifetime(1.0,2.0); + surfaceVfxEmitter.setSpeed(80.0,120.0);surfaceVfxEmitter.setDirection(1.5708); + surfaceVfxEmitter.setParticleSize(2.0,4.0);surfaceVfxEmitter.setColorStart(0.7,0.82,0.92,0.45); + surfaceVfxEmitter.setColorEnd(0.55,0.68,0.80,0.0); + requireResult(eve.applyUnderwaterParticles(underwaterParticleEmitter,transitionParticleEmitter, + underwaterOutput.particles,underwaterOutput.transitionFx, + underwaterOutput.entered,underwaterOutput.exited),"particle provider"); + requireResult(eve.applyUnderwaterSurfaceVfx(surfaceVfxEmitter,underwaterOutput.surfaceVfx),"surface vfx provider"); + requireResult(eve.applyWaterUnderwaterHorizon(horizonEntity,underwaterOutput),"horizon provider"); + if(!underwaterParticleEmitter.isActive()||!horizonEntity.getVisible()|| + !surfaceVfxEmitter.isStopped()||surfaceVfxEmitter.isVisible()) + throw "pcg-underwater: visual lifecycle mismatch"; +} +function verifyDisableTriggerLifecycle(){ + local groundParticles=retain(eve.Particles());groundParticleEmitter=retain(groundParticles.newEmitter(64)); + groundParticleEmitter.stop(); + groundParticleEmitter.setSimulationSpace("world");groundParticleEmitter.setPosition(1200.0,0.0); + groundParticleEmitter.emit(2); + originShiftedParticles=requireResult(eve.shiftWorldSpaceParticles(groundParticleEmitter,1000.0,0.0),"particle origin shift"); + if(originShiftedParticles!=2||!groundParticleEmitter.isStopped())throw "pcg-underwater: particle origin shift mismatch"; + local physics=retain(eve.Physics());triggerWorld=retain(physics.newWorld3D(0.0,0.0,0.0,false)); + local zone=triggerWorld.newBody("static",0.0,0.0,0.0);local sensor=zone.newBoxShape(4.0,4.0,4.0,1.0,0.5,0.0); + sensor.setTag(8101);sensor.setSensor(true); + local visitor=triggerWorld.newBody("dynamic",0.0,0.0,0.0);local visitorShape=visitor.newSphereShape(0.5,1.0,0.5,0.0); + local gravityWait=retain(eve.TerrainLoadGravityState()); + requireResult(eve.beginTerrainLoadGravity(gravityWait,visitor,true),"terrain gravity begin"); + if(visitor.getGravityScale()!=0.0)throw "pcg-underwater: terrain wait did not suppress gravity"; + requireResult(eve.advanceTerrainLoadGravity(gravityWait,visitor,false,0.5,2.0),"terrain gravity wait"); + requireResult(eve.advanceTerrainLoadGravity(gravityWait,visitor,true,0.75,2.0),"terrain gravity schedule"); + terrainGravityActivated=requireResult(eve.advanceTerrainLoadGravity(gravityWait,visitor,false,1.25,2.0),"terrain gravity activate"); + if(!terrainGravityActivated||visitor.getGravityScale()!=1.0)throw "pcg-underwater: terrain gravity did not restore"; + visitorShape.setTag(8102);triggerWorld.update(0.0166667); + if(triggerWorld.getBeginTriggerCount()!=1)throw "pcg-underwater: missing disable trigger enter"; + disableTriggerState=retain(eve.WaterUnderwaterDisableTriggerState());local event=retain(eve.WaterUnderwaterTriggerEvent()); + event.sensorTag=triggerWorld.getBeginTriggerSensorShapeTag(0);event.visitorTag=triggerWorld.getBeginTriggerVisitorShapeTag(0); + event.entered=true;requireResult(eve.advanceWaterUnderwaterDisableTrigger(disableTriggerState,event,8101,8102),"trigger enter"); + requireResult(eve.applyGroundParticleCulling(groundParticleEmitter,8102,event.visitorTag,true,false),"ground particle enter"); + if(!groundParticleEmitter.isActive())throw "pcg-underwater: ground particles did not start"; + if(disableTriggerState.effectsEnabled)throw "pcg-underwater: disable trigger did not suppress effects"; + visitor.setPosition(10.0,0.0,0.0);triggerWorld.update(0.0166667); + if(triggerWorld.getEndTriggerCount()!=1)throw "pcg-underwater: missing disable trigger exit"; + event.entered=false;event.exited=true;event.sensorTag=triggerWorld.getEndTriggerSensorShapeTag(0); + event.visitorTag=triggerWorld.getEndTriggerVisitorShapeTag(0); + requireResult(eve.advanceWaterUnderwaterDisableTrigger(disableTriggerState,event,8101,8102),"trigger exit"); + requireResult(eve.applyGroundParticleCulling(groundParticleEmitter,8102,event.visitorTag,false,true),"ground particle exit"); + if(!groundParticleEmitter.isStopped())throw "pcg-underwater: ground particles did not stop"; + if(!disableTriggerState.effectsEnabled)throw "pcg-underwater: disable trigger did not restore effects"; +} + +eve_init=function(){ + verifyDisableTriggerLifecycle(); + buildCausticCookie(); + gfx.setBackgroundColor(0.015,0.06,0.10,1.0); + gfx.setDirectionalLight(-0.35,0.75,0.45,0.46,0.68,0.82); + gfx.getRenderControl().enable("gbuffer"); + camera=retain(eve.Camera3D());camera.setEye(0.0,4.21,12.0);camera.setTarget(0.0,4.5,-10.0); + camera.setUp(0.0,1.0,0.0);camera.setFov(58.0);camera.setClipPlanes(0.1,120.0); + camera.setAmbient(0.10,0.18,0.24);camera.setActive(true); + + local cube=retain(gfx.newMeshCube(1.0)); + object(cube,0.0,-2.6,-8.0,28.0,1.0,38.0,0.18,0.26,0.20); + object(cube,-5.0,-0.7,-7.0,2.2,3.4,2.2,0.22,0.34,0.28); + object(cube,4.0,-1.2,-13.0,3.0,2.4,3.0,0.16,0.29,0.25); + object(cube,0.5,-0.4,-20.0,1.4,4.2,1.4,0.25,0.38,0.31); + horizonEntity=object(cube,0.0,3.6,-31.0,42.0,0.7,1.0,0.04,0.19,0.30); + horizonEntity.setCastShadow(false);horizonEntity.setVisible(false); + + water=retain(gfx.newWater());local mesh=eve.WaterMeshSettings();mesh.setType(0); + mesh.sizeX=36.0;mesh.sizeZ=48.0;mesh.densityX=3.0;mesh.densityY=3.0; + requireResult(water.createProceduralMesh(mesh),"water mesh");water.setWaveAmplitude(0.12); + waterEntity=object(water.getMesh(),0.0,4.25,-8.0,1.0,1.0,1.0,0.12,0.54,0.72); + local followSettings=retain(eve.FollowPlayerSettings());followSettings.waterObject=true; + followSettings.useOffset=true;followSettings.offsetX=0.0;followSettings.offsetY=9.96;followSettings.offsetZ=20.0; + local followInput=retain(eve.FollowPlayerInput());followInput.hasPlayer=true; + followInput.playerX=0.0;followInput.playerY=4.21;followInput.playerZ=12.0; + local followOutput=retain(eve.FollowPlayerOutput()); + requireResult(eve.evaluateFollowPlayer(followOutput,followSettings,followInput),"follow water"); + if(!followOutput.applyPosition)throw "pcg-underwater: follow water output missing"; + waterEntity.setPosition(followOutput.x,followOutput.y,followOutput.z);followWaterApplied=true; + waterEntity.setShader(water.getShader());waterEntity.setCastShadow(false);waterEntity.setReceiveShadow(false); + underwaterMaterialEntity=object(cube,0.0,4.25,-8.0,36.0,0.05,48.0,0.12,0.54,0.72); + underwaterMaterialEntity.setShader(water.getShader());underwaterMaterialEntity.setCastShadow(false); + underwaterMaterialEntity.setReceiveShadow(false); + + underwaterSettings=retain(eve.WaterUnderwaterSettings());underwaterSettings.causticTextureCount=16; + underwaterSettings.enabled=disableTriggerState.effectsEnabled; + underwaterSettings.fogDepth=20.0;underwaterSettings.fogDensity=0.052;underwaterSettings.fogDistance=42.0; + underwaterSettings.setFogColorMultiplier(0.02,-1.0,0.0); + underwaterInput=retain(eve.WaterUnderwaterInput());underwaterInput.seaLevel=4.25; + underwaterInput.cameraY=6.0;underwaterInput.setMainLightColor(0.72,0.88,1.0); + underwaterState=retain(eve.WaterUnderwaterState());underwaterOutput=retain(eve.WaterUnderwaterOutput()); + depthGradient=retain(eve.UnderwaterColorGradient());timeGradient=retain(eve.UnderwaterColorGradient()); + postExposureCurve=retain(eve.UnderwaterScalarCurve());postColorGradient=retain(eve.UnderwaterColorGradient()); + requireResult(depthGradient.addStop(0.0,0.08,0.48,0.62),"shallow fog"); + requireResult(depthGradient.addStop(0.5,0.025,0.20,0.34),"middle fog"); + requireResult(depthGradient.addStop(1.0,0.005,0.035,0.09),"deep fog"); + requireResult(timeGradient.addStop(0.0,0.03,0.12,0.22),"night fog"); + requireResult(timeGradient.addStop(1.0,0.09,0.42,0.55),"day fog"); + requireResult(postExposureCurve.addKey(0.0,-1.0),"night exposure"); + requireResult(postExposureCurve.addKey(1.0,-0.35),"day exposure"); + requireResult(postColorGradient.addStop(0.0,0.35,0.58,0.78),"night color filter"); + requireResult(postColorGradient.addStop(1.0,0.72,0.92,1.0),"day color filter"); + underwaterSettings.timeDrivenPostFx=true;underwaterInput.timeOfDay=0.5; + requireResult(eve.advanceWaterUnderwaterEffects(underwaterState,underwaterOutput,underwaterSettings, + underwaterInput,depthGradient,timeGradient, + postExposureCurve,postColorGradient),"surface seed"); + underwaterInput.cameraY=4.21;underwaterInput.causticTicks=3; + requireResult(eve.advanceWaterUnderwaterEffects(underwaterState,underwaterOutput,underwaterSettings, + underwaterInput,depthGradient,timeGradient, + postExposureCurve,postColorGradient),"submerge"); + if(!underwaterOutput.entered||!underwaterOutput.loopAudio||underwaterOutput.causticFrame!=3) + throw "pcg-underwater: transition mismatch"; + surfaceFog=retain(eve.WaterSurfaceFogSnapshot());surfaceFog.setColor(0.55,0.58,0.62); + fog=retain(gfx.newVolumetric());fog.setCamera(0.0,4.21,12.0,0.0,4.5,-10.0,0.0,1.0,0.0,58.0,1.5,0.1,120.0); + fog.setFogNoise(0.10);requireResult(eve.applyWaterUnderwaterFog(fog,underwaterOutput,surfaceFog),"fog provider"); + surfacePostFx=retain(eve.WaterSurfacePostFxSnapshot());surfacePostFx.exposureEv=0.0;surfacePostFx.setColor(1.0,1.0,1.0); + requireResult(eve.applyWaterUnderwaterPostFx(gfx,camera,underwaterOutput,surfacePostFx),"post fx provider"); + surfaceMaterial=retain(eve.WaterSurfaceMaterialSnapshot());surfaceMaterial.setColor(0.12,0.54,0.72); + requireResult(eve.applyWaterUnderwaterMaterial(underwaterMaterialEntity,underwaterOutput,surfaceMaterial),"underwater material"); + if(abs(underwaterMaterialEntity.getTintR()-0.72)>0.001) + throw "pcg-underwater: underwater material color mismatch"; + setupUnderwaterAudio(); + setupUnderwaterVisualLifecycle(); + print("PCG_UNDERWATER_READY depth="+underwaterOutput.depth01+" caustic="+underwaterOutput.causticFrame+" fog="+underwaterOutput.getFogB()+" transition="+underwaterOutput.transitionWeight+" audio=looping particles=active surfaceVfx=paused trigger=restored groundParticles=culled originParticles="+originShiftedParticles+" terrainGravity=activated followWater=applied material=mainLight horizon=visible\n"); +}; + +eve_update=function(dt){seconds+=dt;water.update(dt);fog.setTime(seconds);}; +eve_render=function(){ + gfx.clear();gfx.render3D(); + local depth=gfx.getRenderControl().getGBuffer().getDepthTexture(); + if(underwaterOutput.caustics)requireResult(fog.projectDirectionalCookie(gfx,depth,causticTexture,underwaterOutput.causticSize,0.85),"caustic projector"); + fog.applyFog(gfx,depth); +}; diff --git a/examples/pcg-underwater/submerge-down.wav b/examples/pcg-underwater/submerge-down.wav new file mode 100644 index 00000000..5a206fd4 Binary files /dev/null and b/examples/pcg-underwater/submerge-down.wav differ diff --git a/examples/pcg-underwater/submerge-up.wav b/examples/pcg-underwater/submerge-up.wav new file mode 100644 index 00000000..f7aa9594 Binary files /dev/null and b/examples/pcg-underwater/submerge-up.wav differ diff --git a/examples/pcg-underwater/underwater-loop.wav b/examples/pcg-underwater/underwater-loop.wav new file mode 100644 index 00000000..ac91ddaa Binary files /dev/null and b/examples/pcg-underwater/underwater-loop.wav differ diff --git a/examples/procedural-road/.gitignore b/examples/procedural-road/.gitignore new file mode 100644 index 00000000..b74f6243 --- /dev/null +++ b/examples/procedural-road/.gitignore @@ -0,0 +1,3 @@ +procedural-road.png +procedural-road-*.png +scene.txt diff --git a/examples/procedural-road/README.md b/examples/procedural-road/README.md new file mode 100644 index 00000000..6e0329f6 --- /dev/null +++ b/examples/procedural-road/README.md @@ -0,0 +1,27 @@ +# Procedural Road Lab + +纯数学生成道路(无预制道路/桥梁模型)。简单场景验证几何后,打开复杂立交: + +| 键 | scene | 内容 | +|----|-------|------| +| 1 | `straight` | 平坦直线段(无路口) | +| 2 | `curve` | 水平弯道(无路口) | +| 3 | `bridge` | 等高高架直线 + 桥墩 | +| 4 | `cross` | 地面十字路口(四臂外推 + 圆心朝外圆弧转角) | +| 5 | `interchange` | 地面十字 + 高架环 + 对角上跨 + 两条匝道 | + +```sh +make run GAME=examples/procedural-road +# headless:用 scene.txt / EVENGINE_ROAD_SCENE 指定场景 +echo interchange > examples/procedural-road/scene.txt +VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json ALSOFT_DRIVERS=null \ + xvfb-run -a scripts/smoke_examples.sh procedural-road +``` + +配方参数:`scene=straight|curve|bridge|cross|interchange`,另有 `mesh.roadNetwork` / `tex.roadMarkings`。 +设计说明见 `docs/dev/程序化道路系统设计.md`。 + +> Cloud / Lavapipe 注意:本机无 GPU,Vulkan ICD 是 Mesa Lavapipe(“显存”走主机 RAM)。 +> 立交 + 桥墩在约 15Gi 主机内存上会被 Linux OOM killer 杀掉(`dmesg` 可见 +> `Killed process … (eve) anon-rss:~15Gi`)。这与真实显卡的 32G VRAM 无关; +> 本地有 GPU 时直接 `make run GAME=examples/procedural-road` 即可看完整桥墩。 diff --git a/examples/procedural-road/config.nut b/examples/procedural-road/config.nut new file mode 100644 index 00000000..78b1ff0d --- /dev/null +++ b/examples/procedural-road/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1280 + height = 720 + title = "Procedural Road Lab" + debug = false + hotReload = true +}; diff --git a/examples/procedural-road/main.nut b/examples/procedural-road/main.nut new file mode 100644 index 00000000..f10c7987 --- /dev/null +++ b/examples/procedural-road/main.nut @@ -0,0 +1,193 @@ +// Procedural Road Lab — simple scenes (1-4) + complex interchange (5). +// Keys 1-5 switch scenes. Headless: write scene name into scene.txt. + +persist roadParts = [] +persist roadGround = null +persist roadCamera = null +persist roadFrame = 0 +persist roadScreenshotSaved = false +persist roadReady = false +persist roadMaterials = {} +persist roadPrevKeys = {} +persist roadSceneName = "straight" +persist roadSceneList = ["straight", "curve", "bridge", "cross", "interchange"] + +function roadRequire(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result; +} + +function roadPressed(k) { + local down = keyboard.isDown(k); + local old = k in roadPrevKeys ? roadPrevKeys[k] : false; + roadPrevKeys[k] <- down; + return down && !old; +} + +function roadStyleForGroup(name) { + if (name == "asphalt") return [0.20, 0.20, 0.22, 0.95, 0.0, true]; + if (name == "curb") return [0.62, 0.62, 0.60, 0.55, 0.02, true]; + if (name == "sidewalk") return [0.90, 0.90, 0.88, 0.72, 0.0, true]; + if (name == "deck") return [0.40, 0.40, 0.38, 0.88, 0.0, true]; + if (name == "pier") return [0.74, 0.72, 0.68, 0.62, 0.0, true]; + if (name == "marking") return [0.98, 0.98, 0.96, 0.30, 0.0, false]; + if (name == "markingYellow") return [0.98, 0.82, 0.08, 0.35, 0.0, false]; + if (name == "nav") return [0.05, 0.95, 1.0, 0.18, 0.0, false]; + return [0.55, 0.55, 0.52, 0.75, 0.0, true]; +} + +function roadClearParts() { + foreach (part in roadParts) { + part.setVisible(false); + part.setMesh(null); + } + roadParts = []; +} + +function roadCameraForScene(name) { + if (name == "straight") { + roadCamera.setEye(22.0, 16.0, 18.0); + roadCamera.setTarget(0.0, 0.5, 0.0); + } else if (name == "curve") { + roadCamera.setEye(16.0, 18.0, 8.0); + roadCamera.setTarget(-4.0, 0.5, 6.0); + } else if (name == "bridge") { + roadCamera.setEye(20.0, 12.0, 18.0); + roadCamera.setTarget(0.0, 2.5, 0.0); + } else if (name == "cross") { + roadCamera.setEye(0.0, 28.0, 18.0); + roadCamera.setTarget(0.0, 0.3, 0.0); + } else { + // interchange — overview showing ground cross, ring, and ramps + roadCamera.setEye(42.0, 34.0, 18.0); + roadCamera.setTarget(0.0, 4.0, 0.0); + } +} + +function roadBuildScene(name) { + roadClearParts(); + roadSceneName = name; + roadScreenshotSaved = false; + roadFrame = 0; + + local params = roadRequire(procgen.newParams(), "new params").value; + roadRequire(params.setSeed(1), "seed"); + params.setString("scene", name); + params.setFloat("span", name == "interchange" ? 48.0 : (name == "cross" ? 28.0 : 32.0)); + params.setFloat("bridgeHeight", name == "interchange" ? 8.0 : 6.0); + params.setInt("lanes", 2); + local segs = 28; + if (name == "curve" || name == "bridge") segs = 48; + if (name == "interchange") segs = 20; + params.setInt("pathSegments", segs); + params.setBool("piers", true); + params.setBool("markings", true); + // Nav overlay is noisy on the complex scene; keep it off for the visual pass. + params.setBool("navigation", false); + params.setBool("junctions", name == "cross" || name == "interchange"); + + local cpu = roadRequire(procgen.buildMesh("mesh.roadNetwork", params), "buildMesh").value; + local groupSummary = ""; + for (local i = 0; i < cpu.getGroupCount(); ++i) { + local component = cpu.copyGroup(i); + if (component == null || component.empty()) continue; + local mesh = roadRequire(procgen.uploadMesh(component, gfx), "upload " + name + " " + i).value; + local part = eve.Renderable3D(); + local gname = cpu.getGroupName(i); + local style = roadStyleForGroup(gname); + if (!(gname in roadMaterials)) { + local material = gfx.newMaterial(); + material.setTint(style[0], style[1], style[2], 1.0); + material.setRoughness(style[3]); + material.setMetallic(style[4]); + material.setReceiveLight(style[5]); + if (!style[5]) material.setShadingModel("unlit"); + roadMaterials[gname] <- material; + } + part.setMesh(mesh); + part.setMaterial(roadMaterials[gname]); + part.setCastShadow(gname != "nav" && gname != "marking" && gname != "markingYellow"); + part.setReceiveShadow(gname == "asphalt" || gname == "sidewalk" || gname == "deck" || gname == "curb"); + roadParts.append(part); + groupSummary += gname + ":" + component.getVertexCount() + " "; + } + + // Interchange needs a larger ground plate. + if (roadGround != null) { + local gscale = name == "interchange" ? 110.0 : 70.0; + roadGround.setScale(gscale, 0.4, gscale); + } + + roadCameraForScene(name); + print("PROCEDURAL_ROAD_SCENE scene=" + name + " verts=" + cpu.getVertexCount() + + " groups=" + cpu.getGroupCount() + " parts=" + roadParts.len() + " [" + groupSummary + "]\n"); + roadReady = true; +} + +if (roadCamera == null) { + roadCamera = eve.Camera3D(); + roadCamera.setUp(0.0, 1.0, 0.0); + roadCamera.setFov(42.0); + roadCamera.setClipPlanes(0.5, 280.0); + roadCamera.setAmbient(0.42, 0.44, 0.48); + roadCamera.setActive(true); + gfx.setDirectionalLight(-0.40, -1.0, -0.30, 1.55, 1.48, 1.35); + gfx.setBackgroundColor(0.55, 0.18, 0.14, 1.0); +} + +if (roadGround == null) { + roadGround = eve.Renderable3D(); + roadGround.setMesh(gfx.newMeshCube(1.0)); + roadGround.setPosition(0.0, -0.55, 0.0); + roadGround.setScale(70.0, 0.4, 70.0); + local groundMat = gfx.newMaterial(); + groundMat.setTint(0.78, 0.16, 0.12, 1.0); + groundMat.setRoughness(0.98); + roadGround.setMaterial(groundMat); + roadGround.setCastShadow(false); + roadGround.setReceiveShadow(true); +} + +if (!roadReady) { + local boot = "straight"; + try { + local handle = file("scene.txt", "r"); + if (handle != null) { + local content = handle.read(); + handle.close(); + if (content != null) { + local s = ""; + local raw = content.tostring(); + for (local i = 0; i < raw.len(); ++i) { + local ch = raw.slice(i, i + 1); + if (ch == "\n" || ch == "\r" || ch == " ") break; + s += ch; + } + if (s == "straight" || s == "curve" || s == "bridge" || s == "cross" || s == "interchange") + boot = s; + } + } + } catch (e) {} + roadBuildScene(boot); +} + +function eve_update(dt) { + if (!roadReady) return; + roadFrame += 1; + + if (roadPressed("1")) roadBuildScene("straight"); + if (roadPressed("2")) roadBuildScene("curve"); + if (roadPressed("3")) roadBuildScene("bridge"); + if (roadPressed("4")) roadBuildScene("cross"); + if (roadPressed("5")) roadBuildScene("interchange"); + + if (!roadScreenshotSaved && roadFrame > 24) { + local file = "procedural-road-" + roadSceneName + ".png"; + if (gfx.saveFramePng(file)) { + roadScreenshotSaved = true; + print("procedural-road: screenshot " + file + "\n"); + } + } +} + +function eve_render() { gfx.clear(); gfx.render3D(); } diff --git a/examples/rendering-chain-lab/README.md b/examples/rendering-chain-lab/README.md new file mode 100644 index 00000000..06520e19 --- /dev/null +++ b/examples/rendering-chain-lab/README.md @@ -0,0 +1,57 @@ +# Rendering Chain Lab — TAA / SSR / RTGI 与自动反射链运行时对比场景 + +本示例提供一个固定视角的对比场景,用同一条渲染链交替开关 TAA、SSR、RTGI 与自动反射链 +(`reflectionChain`),让屏幕空间反射、间接光颜色溢出和时间性闪烁/拖影的差异在同一画面上 +直接对照。场景里的反光地面、饱和遮挡盒、五种粗糙度的球体和一根来回移动的细杆,都是为暴露 +这些通道的典型缺陷而摆放的。 + +## 运行 + +```bash +make run/-debug GAME=examples/rendering-chain-lab +``` + +也可以进入示例目录后直接启动引擎: + +```bash +cd examples/rendering-chain-lab && ../../build/linux-debug/src/engine/eve run --debug --mcp-port=7529 +``` + +Windows 下引擎可执行文件为 `build/win32-debug/src/engine/eve.exe`。脚本头部记录的等价形式是 +`eve run --debug --mcp-port=7529 examples/rendering-chain-lab`。 + +## 演示内容 + +- 场景搭建:`buildScene()` 用 `eve.Renderable3D` 配 `gfx.newMeshCube(1.0)` / + `gfx.newMeshSphere(40, 24)` 生成反光地面与后墙(`setMetallic(0.72)`、`setRoughness(0.12)`)、 + 红蓝两块高饱和遮挡盒,以及 5 个球体(`setMetallic(0.48)`,粗糙度依次为 + 0.06 / 0.16 / 0.30 / 0.52 / 0.82)和各自的底座。 +- 时间性伪影诱因:细杆 `labMover` 在 `eve_update` 中按 `sin(labTime * 0.82) * 2.8` 横向移动, + 用来观察 TAA 的闪烁与拖影。 +- 相机与光照:`eve.Camera3D` 设 `setFov(48.0)`、`setAmbient(...)`、`setExposure(0.85)`、 + `setBloom(0.18, 1.15)` 并 `setActive(true)`;`gfx.setDirectionalLight` 提供主光,两个 + `eve.Light3D` 暖 / 冷点光源补光,`gfx.setBackgroundColor` 定下暗色基调。 +- 渲染链开关:`configureEffects()` 取 `gfx.getRenderControl()`,先执行 + `rc.setPostProcessQuality("high")`,再对 `gi`、`rtgi`、`aa`、`taa`、`ssr`、`reflectionChain` + 逐个 `enable` / `disable`,最后 `rc.compile()`。 +- 反射链是一键式的:开启 `reflectionChain` 会同时打开 `aa`、`taa`、`rtgi` 并关闭 `msaa`, + 三者共用同一套后处理质量档位。 +- 回调结构:`eve_init` 建场景与 HUD,`eve_update(dt)` 推进动画并处理按键,`eve_render` 依次 + 调用 `gfx.clear()`、`gfx.render3D()`、`ui.beginFrameAndRender()`。 + +## 操作 + +| 输入 | 作用 | +|---|---| +| `Space` | 在 FULL(TAA + SSR + RTGI + 反射链)与 BASELINE(上述特性全部关闭)之间切换 | +| `R` / `r` | 复位相机到 `setEye(0.0, 4.4, 12.8)` / `setTarget(0.0, 1.0, -0.8)` | + +HUD(`ui.beginWindow("RenderingChainLab")`,经 `ui.mountBuildAs("render-lab-hud")` 挂载为 +宿主叠加层)只显示标题、当前模式和这两条按键提示,没有可点击控件;脚本也没有注册任何鼠标 +操作,相机是固定视角。 + +## 相关文件 + +- `main.nut`:示例全部逻辑——场景搭建、渲染链开关、HUD 以及三个引擎回调。 +- `config.nut`:窗口配置,1280×720,标题 `EVEngine Rendering Chain Lab`,`debug = true`。 +- `README.md`:本文件。 diff --git a/examples/shader-live-preview/README.md b/examples/shader-live-preview/README.md new file mode 100644 index 00000000..34709215 --- /dev/null +++ b/examples/shader-live-preview/README.md @@ -0,0 +1,56 @@ +# Shader Live Preview — 实时 GLSL 热重载预览 + +该示例用一份自定义 GLSL 片元着色器渲染由球体、立方体、圆柱体和地面组成的 3D 预览场景, +并开启引擎的资产热重载:保存 `shaders/preview.frag` 即会重新编译并事务式替换管线, +无需重启引擎或重新构建。窗口为 960×540,标题 `EVEngine Live Shader Preview`。 + +## 运行 + +```bash +make run/-debug GAME=examples/shader-live-preview +``` + +也可构建后进入示例目录直接启动引擎: + +```bash +cd examples/shader-live-preview && ../../build/linux-debug/src/engine/eve run +``` + +Windows 上引擎可执行文件为 `build/win32-debug/src/engine/eve.exe`。 + +## 演示内容 + +- 场景搭建:`eve.Camera3D()` 的 `setEye/setTarget/setUp/setFov/setAmbient/setActive`、 + `gfx.setDirectionalLight`,以及 `gfx.newMeshSphere(48, 24)`、`gfx.newMeshCube(1.0)`、 + `gfx.newMeshCylinder(48, 1, true)` 创建的四个 `eve.Renderable3D()` 模型。 +- 模型设置:`setMesh`、`setShader`、`setPosition`、`setScale`、`setTint`、`setRoughness`、 + `setCastShadow`、`setReceiveShadow`。 +- 自定义着色器:`gfx.newMeshShader(fs.readText("shaders/preview.frag"))`(顶点源码留空), + `declareFloat("time")` 声明推送常量,`eve_update` 中每帧 `sendFloat("time", previewTime)`。 +- 片元入口 `main()`:读取 `Frame` UBO 的 `lightDirIntensity` 与 `ambient`,以推送常量 `pc.data[0]` + 作为时间,混合 `vTint`、漫反射、边缘光,以及随时间摆动的色带与 UV 网格线。 +- 渲染:`gfx.clear()` + `gfx.render3D()`;`eve_update(dt)` 让前三个模型按 `setYaw(previewTime * ...)` 自转。 + +## 热重载 + +`config.nut` 中 `hotReload = true`。启动时引擎打印 `hot-reload: watching N path(s)`, +通过 `hot.watchTree(".")` 监视示例目录整棵树;非 `.nut` 文件变更后调用脚本钩子 +`eve_asset_reload(path)`,`main.nut` 用 `path.find("preview.frag")` 过滤,只处理该文件。 + +保存 `shaders/preview.frag` 后的流程:`fs.readText("shaders/preview.frag")` 重新读取源码, +再调用 `gfx.replaceShaderFromGlsl(previewShader, "", source)` 事务式替换管线 +(空顶点源码表示使用该着色器类型的后端默认顶点着色器)。 +编译成功打印 `[shader-preview] reload applied`;失败打印 `[shader-preview] <诊断信息>`, +保留上一份可用管线,并把状态写入 `reloadMessage`。 +直接编辑 `main()` 中的配色、`bands` 频率或 `grid` 阈值即可立刻看到差别。 + +## 操作 + +本示例未注册任何键盘或鼠标输入,没有交互按键。`previewTime` 自动累加驱动动画与推送常量, +改完着色器保存即可观察热重载效果。 + +## 相关文件 + +- `config.nut`:窗口尺寸/标题、`modules = ["gfx", "win", "fs"]` 与 `hotReload` 开关。 +- `main.nut`:搭建预览场景、注册 `eve_asset_reload` 热重载钩子、每帧推送 `time` 并驱动自转。 +- `shaders/preview.frag`:唯一的着色器源码,也是热重载的编辑目标(`#version 450` 片元着色器)。 diff --git a/examples/silpom-ssdm-compare/.gitignore b/examples/silpom-ssdm-compare/.gitignore new file mode 100644 index 00000000..f60d3c55 --- /dev/null +++ b/examples/silpom-ssdm-compare/.gitignore @@ -0,0 +1,2 @@ +eve_inspect_cache/ +mcp_screenshot.png diff --git a/examples/silpom-ssdm-compare/README.md b/examples/silpom-ssdm-compare/README.md new file mode 100644 index 00000000..f4dda7ac --- /dev/null +++ b/examples/silpom-ssdm-compare/README.md @@ -0,0 +1,79 @@ +# SilPOM vs SSDM — full planar algorithms + +Side-by-side comparison of **classic POM**, **SilPOM**, and **planar SSDM** +on extruded brick cards with hard flat height plateaus. + +| Card | Technique | What you should notice | +| --- | --- | --- | +| Left (warm) | Classic **POM** | Internal brick depth; **straight geometric** silhouette | +| Mid (green) | **SilPOM** | Solid heightfield shading; **jagged brick-cap** side limb | +| Right (cool) | **Planar SSDM** | Same solid march; **continuous** extruded outline | + +Cards are **extruded slabs** (local Z = height axis). That is the correct domain +for silhouette-changing relief. Cylinders are not — a wrapped UV chart cannot +grow the outline, and a constant depth bias is not SSDM. + +## Run + +```sh +make run/linux-debug GAME=examples/silpom-ssdm-compare +``` + +Headless smoke (Cloud VM): + +```sh +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" +xvfb-run -a scripts/smoke_examples.sh silpom-ssdm-compare +``` + +## Controls + +| Key | Action | +| --- | --- | +| `O` | Toggle auto-orbit | +| `A` / `D` | Yaw | +| `W` / `S` | Pitch | +| `[` / `]` | Relief scale | +| `-` / `=` | Max ray-march layers | +| `1` / `2` / `3` | Focus POM / SilPOM / SSDM | + +## Implementation notes + +- Shared CPU references: `src/modules/graphics/ParallaxMap.h` +- Shared GLSL helpers: `src/modules/graphics/shaders/parallax_map.glsl`, + `ssdm.glsl` +- Demo shader is self-contained (`shaders/compare.frag`): + - Heightmap is **hard flat brick** with a 1-texel mortar bevel (no soft pillows) + - **SilPOM / SSDM** share the solid planar heightfield march (`hit.z` is a + hit flag, not softCoverage) so cliff walls shade correctly + - Front faces keep geometric depth (cliff FragDepth opens 镂空 shells) + - Side faces write hit depth; SilPOM discards low mortar (jagged brick caps); + SSDM keeps the continuous profile + - Classic POM fades at grazing angles to limit hard-cliff zebra striping +- Meshes: thin front card for classic POM; extruded slab for SilPOM/SSDM +- `gl_FragDepth` only pulls toward the camera (Vulkan RH_ZO) + +### Regenerating the shader + +The fragment stage ships as **committed SPIR-V** (`shaders/compare.frag.spv`), +loaded with `gfx.loadMeshShaderSpv`, so the example needs no runtime compiler and +renders the same stages on every host. After editing `shaders/compare.frag`, +regenerate the committed artifact with: + +```sh +glslc -o shaders/compare.frag.spv shaders/compare.frag +``` + +Hot reload (`eve_asset_reload`) recompiles the GLSL live through the engine's own +compiler; where that is unavailable the panels keep the committed SPIR-V and the +status line reports it. + +## Honesty bound + +True screen-space SSDM as a deferred/post pass (pyramid mip + screen warp over +an already-rendered buffer) is **not** shipped as an engine post here. The demo +implements the **geometrically correct planar** form: raymarch the heightfield +inside the extruded volume and write depth from the hit. diff --git a/examples/silpom-ssdm-compare/config.nut b/examples/silpom-ssdm-compare/config.nut new file mode 100644 index 00000000..7bc7f2e8 --- /dev/null +++ b/examples/silpom-ssdm-compare/config.nut @@ -0,0 +1,7 @@ +config = { + width = 1280 + height = 720 + title = "EVEngine — SilPOM vs SSDM" + debug = true + hotReload = true +}; diff --git a/examples/silpom-ssdm-compare/main.nut b/examples/silpom-ssdm-compare/main.nut new file mode 100644 index 00000000..0b74ac5d --- /dev/null +++ b/examples/silpom-ssdm-compare/main.nut @@ -0,0 +1,344 @@ +// Full SilPOM vs full SSDM — planar extruded brick cards. +// +// Left = classic POM (parallax only; geometric silhouette) +// Mid = SilPOM (planar heightfield march + mild geometric-rim clip) +// Right = SSDM (planar heightfield march; side misses discard) +// +// Cards are extruded slabs (local Z = height axis) so SilPOM/SSDM can change +// the silhouette. Cylinders are the wrong domain for these algorithms. +// +// Controls: +// O toggle auto-orbit +// A / D yaw +// W / S pitch +// [ / ] relief scale +// - / = max ray-march layers +// 1 / 2 / 3 focus POM / SilPOM / SSDM + +persist cmpCam = null +persist cmpPom = null +persist cmpSil = null +persist cmpSsdm = null +persist cmpGround = null +persist cmpAlbedo = null +persist cmpHeight = null +persist cmpTexVer = 0 +const CMP_TEX_VER = 16 +persist cmpShaderVer = 0 +const CMP_SHADER_VER = 26 +persist cmpYaw = 0.42 +persist cmpPitch = 0.22 +persist cmpOrbit = false +persist cmpScale = 0.08 +persist cmpMinLayers = 28.0 +persist cmpMaxLayers = 60.0 +persist cmpFocus = 2 +persist cmpStatus = "orbit off" +persist cmpSlabMesh = null +persist cmpThinMesh = null +persist cmpReady = false + +function clampf(v, a, b) { + if (v < a) return a; + if (v > b) return b; + return v; +} + +function asFloat(v) { + // Squirrel sendFloat rejects integers; `* 1.0` always yields a float. + return v * 1.0; +} + +function brickColor(u, v) { + // Running-bond masonry with HARD flat plateaus (no soft sine pillows). + // One-texel bevel only — enough to cut POM zebra, not enough to pillow. + local bu = u * 5.0; + local bv = v * 3.5; + local row = floor(bv); + local odd = (row.tointeger() % 2) == 1; + local ou = odd ? (bu + 0.5) : bu; + local fx = fabs(ou - floor(ou) - 0.5); + local fy = fabs(bv - floor(bv) - 0.5); + // Distance into mortar band (0 = deep inside brick, 1 = deep mortar). + local mx = (fx - 0.365) / 0.020; + local my = (fy - 0.325) / 0.020; + if (mx < 0.0) mx = 0.0; if (mx > 1.0) mx = 1.0; + if (my < 0.0) my = 0.0; if (my > 1.0) my = 1.0; + local mortar = mx; + if (my > mortar) mortar = my; + local tint = 0.92 + 0.08 * (0.5 + 0.5 * sin(floor(ou) * 2.7) * cos(row * 1.9)); + local h = (1.0 - mortar) * 0.90 + mortar * 0.32; + if (mortar > 0.55) + return [0.50, 0.48, 0.45, h]; + return [0.62 * tint, 0.30 * tint, 0.22 * tint, h]; +} + +function buildTextures() { + // 512² + nearest → crisp rectangular brick edges (not soft pillows). + local size = 512; + local albedo = eve.Image().newEmptyImageData(size, size, "RGBA8"); + local height = eve.Image().newEmptyImageData(size, size, "RGBA8"); + for (local y = 0; y < size; y += 1) { + for (local x = 0; x < size; x += 1) { + local u = (x + 0.5) / size.tofloat(); + local v = (y + 0.5) / size.tofloat(); + local c = brickColor(u, v); + albedo.setPixel(x, y, c[0], c[1], c[2], 1.0); + height.setPixel(x, y, c[3], c[3], c[3], 1.0); + } + } + cmpAlbedo = gfx.newTexture(albedo, false, false); + cmpHeight = gfx.newTexture(height, false, false); + // Linear albedo softens chart-edge moire; nearest height keeps hard plateaus. + gfx.setTextureSampler(cmpAlbedo, "linear", "none", 1.0, 0.0); + gfx.setTextureSampler(cmpHeight, "nearest", "none", 1.0, 0.0); +} + +// Unit slab: XY in [-1,1], Z in [0,1] (front at Z=1). UV from XY. +// newMeshFromArrays requires FLOAT position/normal/uv arrays (not integers). +function makeSlabMesh() { + local pos = [ + // front z=1 + -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, + // back z=0 + -1.0, -1.0, 0.0, -1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0,-1.0, 0.0, + // -X + -1.0, -1.0, 0.0, -1.0,-1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 0.0, + // +X + 1.0, -1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0, 1.0, 1.0,-1.0, 1.0, + // -Y + -1.0, -1.0, 0.0, 1.0,-1.0, 0.0, 1.0,-1.0, 1.0, -1.0,-1.0, 1.0, + // +Y + -1.0, 1.0, 0.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 0.0 + ]; + local nrm = [ + 0.0,0.0,1.0, 0.0,0.0,1.0, 0.0,0.0,1.0, 0.0,0.0,1.0, + 0.0,0.0,-1.0, 0.0,0.0,-1.0, 0.0,0.0,-1.0, 0.0,0.0,-1.0, + -1.0,0.0,0.0, -1.0,0.0,0.0, -1.0,0.0,0.0, -1.0,0.0,0.0, + 1.0,0.0,0.0, 1.0,0.0,0.0, 1.0,0.0,0.0, 1.0,0.0,0.0, + 0.0,-1.0,0.0, 0.0,-1.0,0.0, 0.0,-1.0,0.0, 0.0,-1.0,0.0, + 0.0,1.0,0.0, 0.0,1.0,0.0, 0.0,1.0,0.0, 0.0,1.0,0.0 + ]; + local uv = []; + for (local i = 0; i < 24; i += 1) { + local x = pos[i * 3]; + local y = pos[i * 3 + 1]; + uv.push(x * 0.5 + 0.5); + uv.push(y * 0.5 + 0.5); + } + local idx = []; + for (local f = 0; f < 6; f += 1) { + local b = f * 4; + idx.push(b); idx.push(b + 1); idx.push(b + 2); + idx.push(b); idx.push(b + 2); idx.push(b + 3); + } + return gfx.newMeshFromArrays(pos, nrm, uv, 24, idx, 36); +} + +function makeThinMesh() { + local pos = [ + -1.0, -1.0, 1.0, + 1.0, -1.0, 1.0, + 1.0, 1.0, 1.0, + -1.0, 1.0, 1.0 + ]; + local nrm = [0.0,0.0,1.0, 0.0,0.0,1.0, 0.0,0.0,1.0, 0.0,0.0,1.0]; + local uv = [0.0,0.0, 1.0,0.0, 1.0,1.0, 0.0,1.0]; + local idx = [0, 1, 2, 0, 2, 3]; + return gfx.newMeshFromArrays(pos, nrm, uv, 4, idx, 6); +} + +function makeCard(mode, x) { + // The fragment stage ships as committed SPIR-V next to its source, so the + // example needs no runtime compiler and every host renders the same stages. + // Regenerate with + // glslc -o shaders/compare.frag.spv shaders/compare.frag + local loaded = gfx.loadMeshShaderSpv("", "shaders/compare.frag.spv"); + if (!loaded.ok) + throw "silpom-ssdm-compare: compare.frag.spv unavailable: " + loaded.status.summary; + local shader = loaded.value; + shader.declareFloat("mode"); + shader.declareFloat("scale"); + shader.declareFloat("minLayers"); + shader.declareFloat("maxLayers"); + shader.declareFloat("feather"); + shader.declareFloat("horizon"); + shader.sendFloat("mode", asFloat(mode)); + shader.sendFloat("scale", asFloat(cmpScale)); + shader.sendFloat("minLayers", asFloat(cmpMinLayers)); + shader.sendFloat("maxLayers", asFloat(cmpMaxLayers)); + shader.sendFloat("feather", 0.02); + shader.sendFloat("horizon", 0.0); + + local ent = eve.Renderable3D(); + ent.setMesh(mode == 0 ? cmpThinMesh : cmpSlabMesh); + ent.setShader(shader); + ent.setTexture(cmpAlbedo); + ent.setHeightTexture(cmpHeight); + ent.setTint(1.0, 1.0, 1.0, 1.0); + ent.setCastShadow(false); + ent.setReceiveShadow(false); + local thick = (mode == 0) ? 0.02 : clampf(asFloat(cmpScale) * 2.4, 0.12, 0.28); + ent.setPosition(x, 1.0, 0.0); + ent.setScale(1.0, 1.0, thick); + ent.setYaw(-0.15); + return { ent = ent, shader = shader, mode = asFloat(mode), x = x }; +} + + +function syncPanel(panel) { + if (panel == null || panel.shader == null) return; + // sendFloat requires FLOAT; key presses / script assigns can leave integers. + panel.mode = asFloat(panel.mode); + panel.shader.sendFloat("mode", panel.mode); + // Classic POM gets a milder scale — hard cliffs zebra badly at full relief. + local scale = asFloat(cmpScale); + if (panel.mode < 0.5) + scale = scale * 0.55; + panel.shader.sendFloat("scale", scale); + panel.shader.sendFloat("minLayers", asFloat(cmpMinLayers)); + panel.shader.sendFloat("maxLayers", asFloat(cmpMaxLayers)); + panel.shader.sendFloat("feather", 0.02); + panel.shader.sendFloat("horizon", 0.0); + if (panel.mode > 0.5) { + local thick = clampf(asFloat(cmpScale) * 2.4, 0.12, 0.28); + panel.ent.setScale(1.0, 1.0, thick); + } +} + +function updateCamera() { + if (cmpCam == null) return; + local spacing = 2.8; + local focusX = (cmpFocus - 2).tofloat() * spacing; + local dist = 4.6; + local cx = focusX + dist * cos(cmpPitch) * sin(cmpYaw); + local cy = 1.0 + dist * sin(cmpPitch); + local cz = dist * cos(cmpPitch) * cos(cmpYaw); + cmpCam.setEye(cx, cy, cz); + cmpCam.setTarget(focusX, 1.0, 0.0); +} + +eve_init = function() { + cmpReady = false; + gfx.setBackgroundColor(0.10, 0.12, 0.16, 1.0); + if (cmpAlbedo == null || cmpHeight == null || cmpTexVer != CMP_TEX_VER) { + buildTextures(); + cmpTexVer = CMP_TEX_VER; + } + // Persisted panels keep the previous shader program; bump forces a clean rebuild. + if (cmpShaderVer != CMP_SHADER_VER) { + cmpPom = null; + cmpSil = null; + cmpSsdm = null; + cmpShaderVer = CMP_SHADER_VER; + } + if (cmpSlabMesh == null) cmpSlabMesh = makeSlabMesh(); + if (cmpThinMesh == null) cmpThinMesh = makeThinMesh(); + if (cmpPom == null) cmpPom = makeCard(0, -2.8); + if (cmpSil == null) cmpSil = makeCard(1, 0.0); + if (cmpSsdm == null) cmpSsdm = makeCard(2, 2.8); + cmpPom.ent.setTexture(cmpAlbedo); + cmpSil.ent.setTexture(cmpAlbedo); + cmpSsdm.ent.setTexture(cmpAlbedo); + cmpPom.ent.setHeightTexture(cmpHeight); + cmpSil.ent.setHeightTexture(cmpHeight); + cmpSsdm.ent.setHeightTexture(cmpHeight); + if (cmpGround == null) { + cmpGround = eve.Renderable3D(); + cmpGround.setMesh(gfx.newMeshCube(1.0)); + cmpGround.setPosition(0.0, -0.08, 0.2); + cmpGround.setScale(12.0, 0.16, 5.0); + cmpGround.setTint(0.16, 0.17, 0.20, 1.0); + cmpGround.setRoughness(0.95); + cmpGround.setCastShadow(false); + } + if (cmpCam == null) { + cmpCam = eve.Camera3D(); + cmpCam.setFov(40.0); + cmpCam.setAmbient(0.30, 0.32, 0.36); + cmpCam.setActive(true); + } + gfx.setDirectionalLight(-0.25, 0.55, 0.75, 1.40, 1.28, 1.15); + + cmpPom.ent.setMesh(cmpThinMesh); + cmpSil.ent.setMesh(cmpSlabMesh); + cmpSsdm.ent.setMesh(cmpSlabMesh); + cmpPom.ent.setPosition(-2.8, 1.0, 0.0); + cmpSil.ent.setPosition(0.0, 1.0, 0.0); + cmpSsdm.ent.setPosition(2.8, 1.0, 0.0); + cmpPom.ent.setScale(1.0, 1.0, 0.02); + local thick = clampf(asFloat(cmpScale) * 2.4, 0.12, 0.28); + cmpSil.ent.setScale(1.0, 1.0, thick); + cmpSsdm.ent.setScale(1.0, 1.0, thick); + cmpPom.ent.setYaw(-0.15); + cmpSil.ent.setYaw(-0.15); + cmpSsdm.ent.setYaw(-0.15); + + syncPanel(cmpPom); syncPanel(cmpSil); syncPanel(cmpSsdm); + updateCamera(); + cmpReady = true; + print("Full SilPOM vs SSDM: O orbit | A/D yaw | W/S pitch | [/] scale | 1/2/3 focus\n"); + print("Left=POM Mid=SilPOM (solid march + brick-cap limb) Right=SSDM (continuous)\n"); +}; + +eve_asset_reload <- function(path) { + if (!cmpReady) return; + if (path.find("compare.frag") == null) return; + // Live GLSL reload uses the engine's own compiler; when it is missing or the + // source fails to compile, the committed SPIR-V keeps rendering and the status + // line says so instead of failing the example. + local src = fs.readText("shaders/compare.frag"); + foreach (p in [cmpPom, cmpSil, cmpSsdm]) { + local result = gfx.replaceShaderFromGlsl(p.shader, "", src); + if (!result.ok) { + cmpStatus = "GLSL reload failed; keeping committed SPIR-V"; + print("[silpom-ssdm] " + cmpStatus + "\n"); + return; + } + syncPanel(p); + } + cmpStatus = "shader reloaded"; + print("[silpom-ssdm] shader reloaded\n"); +}; + +eve_update = function(dt) { + if (!cmpReady) return; + if (cmpOrbit) cmpYaw += dt * 0.18; + if (key_just_pressed("o") || key_just_pressed("O")) { + cmpOrbit = !cmpOrbit; + cmpStatus = cmpOrbit ? "orbit on" : "orbit off"; + } + if (key_just_pressed("a") || key_just_pressed("A")) cmpYaw -= 0.12; + if (key_just_pressed("d") || key_just_pressed("D")) cmpYaw += 0.12; + if (key_just_pressed("w") || key_just_pressed("W")) + cmpPitch = clampf(cmpPitch + 0.05, 0.05, 0.55); + if (key_just_pressed("s") || key_just_pressed("S")) + cmpPitch = clampf(cmpPitch - 0.05, 0.05, 0.55); + if (key_just_pressed("[")) { + cmpScale = clampf(cmpScale - 0.01, 0.02, 0.16); + cmpStatus = "scale " + cmpScale; + } + if (key_just_pressed("]")) { + cmpScale = clampf(cmpScale + 0.01, 0.02, 0.16); + cmpStatus = "scale " + cmpScale; + } + if (key_just_pressed("-") || key_just_pressed("_")) { + cmpMaxLayers = clampf(cmpMaxLayers - 4.0, 8.0, 64.0); + cmpStatus = "layers " + cmpMaxLayers; + } + if (key_just_pressed("=") || key_just_pressed("+")) { + cmpMaxLayers = clampf(cmpMaxLayers + 4.0, 8.0, 64.0); + cmpStatus = "layers " + cmpMaxLayers; + } + if (key_just_pressed("1")) { cmpFocus = 1; cmpStatus = "focus POM"; } + if (key_just_pressed("2")) { cmpFocus = 2; cmpStatus = "focus SilPOM"; } + if (key_just_pressed("3")) { cmpFocus = 3; cmpStatus = "focus SSDM"; } + + syncPanel(cmpPom); syncPanel(cmpSil); syncPanel(cmpSsdm); + updateCamera(); +}; + +eve_render = function() { + gfx.clear(); + if (cmpReady) gfx.render3D(); +}; diff --git a/examples/silpom-ssdm-compare/shaders/compare.frag b/examples/silpom-ssdm-compare/shaders/compare.frag new file mode 100644 index 00000000..8f87daf3 --- /dev/null +++ b/examples/silpom-ssdm-compare/shaders/compare.frag @@ -0,0 +1,327 @@ +#version 450 +// Full SilPOM vs full SSDM on planar extruded cards. +// +// pc.data[0] mode: +// 0 = classic POM — parallax only; geometric silhouette unchanged +// 1 = SilPOM — solid planar heightfield march (same as SSDM shading) +// + side faces keep brick caps only (jagged limb) +// 2 = SSDM — solid planar heightfield march; side misses discard +// for a continuous extruded outline +// +// Both silhouette modes keep FRONT geometric depth. Writing cliff FragDepth +// on the front opens see-through shells between brick plateaus (镂空). +// Edge protrusion comes from side-face hits + FragDepth. +// +// Height.r: 1 = raised toward the card normal (+Z in model space). +// Card mesh is a slab with local XY in [-1,1] → UV, local Z in [0,1] +// (Z=1 is the front face). Physical thickness comes from model scale.z. + +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; +layout(location = 0) out vec4 outColor; + +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; + vec4 virtualTexture; + vec4 virtualAtlas; + vec4 bindlessEnv; + vec4 envProbeCenter; + vec4 envProbeExtent; + vec4 skinInfo; + vec4 reflectionProbeCenter[2]; + vec4 reflectionProbeExtent[2]; +} ubo; + +layout(set = 0, binding = 1) uniform sampler2D albedoSampler; +layout(set = 0, binding = 6) uniform sampler2D heightSampler; + +layout(push_constant) uniform Externals { float data[32]; } pc; + +mat3 makeTBN(vec3 N, vec3 worldPos, vec2 uv) { + vec3 n = normalize(N); + vec3 dp1 = dFdx(worldPos); + vec3 dp2 = dFdy(worldPos); + vec2 duv1 = dFdx(uv); + vec2 duv2 = dFdy(uv); + float det = duv1.x * duv2.y - duv2.x * duv1.y; + if (abs(det) < 1e-8) + return mat3(vec3(0.0), vec3(0.0), n); + vec3 T = normalize(cross(dp2, n) * duv1.x + cross(n, dp1) * duv2.x); + vec3 B = normalize(cross(dp2, n) * duv1.y + cross(n, dp1) * duv2.y); + if (length(T) < 1e-4 || length(B) < 1e-4) + return mat3(vec3(0.0), vec3(0.0), n); + return mat3(T, B, n); +} + +float heightAt(vec2 uv) { + return texture(heightSampler, clamp(uv, 0.0, 1.0)).r; +} + +vec3 localFromWorld(vec3 worldPos) { + return (inverse(ubo.model) * vec4(worldPos, 1.0)).xyz; +} + +vec2 uvFromLocal(vec3 local) { + return local.xy * 0.5 + 0.5; +} + +float fragDepthFromLocal(vec3 hitLocal) { + vec4 clip = ubo.mvp * vec4(hitLocal, 1.0); + return clamp(clip.z / max(clip.w, 1e-6), 0.0, 1.0); +} + +vec3 heightNormalTS(vec2 uv, float scale) { + vec2 texel = 1.0 / vec2(textureSize(heightSampler, 0)); + float hL = heightAt(uv + vec2(-texel.x, 0.0)); + float hR = heightAt(uv + vec2( texel.x, 0.0)); + float hD = heightAt(uv + vec2(0.0, -texel.y)); + float hU = heightAt(uv + vec2(0.0, texel.y)); + return normalize(vec3((hL - hR) * scale * 5.0, + (hD - hU) * scale * 5.0, + 1.0)); +} + +// Steep POM + linear refine. Returns xy=UV, z=hitDepth[0..1], w=found. +vec4 pomHit(vec2 uv, vec3 viewTS, float scale, float minLayers, float maxLayers) { + if (scale < 1e-5) + return vec4(uv, 0.0, 1.0); + float layers = clamp(mix(max(maxLayers, 1.0), max(minLayers, 1.0), + clamp(abs(viewTS.z), 0.0, 1.0)), + 1.0, 64.0); + float layerDepth = 1.0 / layers; + // Floor |vz| so hard cliffs do not take runaway UV steps (zebra). + float vz = max(abs(viewTS.z), 0.22); + vec2 deltaUV = ((viewTS.xy / vz) * scale) / layers; + vec2 curUV = uv; + float curDepth = 0.0; + float curMap = 1.0 - heightAt(curUV); + bool found = false; + for (int i = 0; i < 64; ++i) { + if (float(i) >= layers) + break; + if (curDepth >= curMap) { + found = true; + break; + } + curUV = clamp(curUV - deltaUV, 0.0, 1.0); + curMap = 1.0 - heightAt(curUV); + curDepth += layerDepth; + } + vec2 prevUV = clamp(curUV + deltaUV, 0.0, 1.0); + float after = curMap - curDepth; + float before = (1.0 - heightAt(prevUV)) - (curDepth - layerDepth); + float denom = after - before; + float w = (abs(denom) < 1e-5) ? 0.5 : clamp(after / denom, 0.0, 1.0); + return vec4(mix(curUV, prevUV, w), + clamp(mix(curDepth, curDepth - layerDepth, w), 0.0, 1.0), + found ? 1.0 : 0.0); +} + +float selfShadow(vec2 hitUV, float hitDepth, vec3 lightTS, float scale, + float minLayers, float maxLayers) { + if (scale < 1e-5 || lightTS.z <= 0.0) + return 1.0; + float layers = clamp(mix(max(maxLayers, 1.0), max(minLayers, 1.0), + clamp(lightTS.z, 0.0, 1.0)), + 1.0, 32.0); + float layerDepth = 1.0 / layers; + float vz = max(lightTS.z, 0.12); + vec2 deltaUV = ((lightTS.xy / vz) * scale) / layers; + vec2 curUV = hitUV; + float curDepth = hitDepth; + for (int i = 0; i < 32; ++i) { + if (float(i) >= layers || curDepth <= 0.0) + break; + curUV = clamp(curUV + deltaUV, 0.0, 1.0); + curDepth -= layerDepth; + if ((1.0 - heightAt(curUV)) < curDepth - 0.015) + return 0.32; + } + return 1.0; +} + +// Geometric heightfield march. Returns xy=UV, z=1 on hit, w=hitZ. +// Adaptive steps near the surface cut cliff stair-steps without soft pillows. +vec4 ssdmMarchLocal(vec3 camLocal, vec3 dirLocal, float minLayers, float maxLayers) { + if (abs(dirLocal.z) < 1e-5) + return vec4(0.0); + float t0 = (1.0 - camLocal.z) / dirLocal.z; + float t1 = (0.0 - camLocal.z) / dirLocal.z; + float tEnter = max(min(t0, t1), 0.0); + float tExit = max(t0, t1); + if (tExit <= tEnter) + return vec4(0.0); + + float layers = clamp(mix(max(maxLayers, 1.0), max(minLayers, 1.0), + clamp(abs(dirLocal.z), 0.0, 1.0)), + 1.0, 64.0); + float dtBase = (tExit - tEnter) / layers; + float t = tEnter; + float prevD = 1.0; + float prevT = tEnter; + for (int i = 0; i < 64; ++i) { + if (float(i) >= layers || t > tExit) + break; + vec3 p = camLocal + dirLocal * t; + vec2 uv = uvFromLocal(p); + if (uv.x < -0.02 || uv.y < -0.02 || uv.x > 1.02 || uv.y > 1.02) { + t += dtBase; + continue; + } + float h = heightAt(clamp(uv, 0.0, 1.0)); + float d = p.z - h; + if (d <= 1e-3) { + float tA = prevT; + float tB = t; + for (int r = 0; r < 7; ++r) { + float tm = 0.5 * (tA + tB); + vec3 pm = camLocal + dirLocal * tm; + if (pm.z <= heightAt(uvFromLocal(pm)) + 1e-3) + tB = tm; + else + tA = tm; + } + vec3 ph = camLocal + dirLocal * tB; + vec2 uvh = clamp(uvFromLocal(ph), 0.0, 1.0); + return vec4(uvh, 1.0, clamp(ph.z, 0.0, 1.0)); + } + float stepT = dtBase; + if (d > 0.0 && d < 0.22) + stepT = min(dtBase, max(dtBase * 0.18, d * 0.40 / max(abs(dirLocal.z), 0.08))); + prevD = d; + prevT = t; + t += stepT; + } + return vec4(0.0); +} + +bool isFrontFace() { + vec3 frontW = normalize(mat3(ubo.model) * vec3(0.0, 0.0, 1.0)); + return abs(dot(normalize(vNormal), frontW)) > 0.55; +} + +vec3 shadeLit(vec3 albedo, vec3 N, vec3 V, float shadow) { + vec3 L = normalize(ubo.lightDirIntensity.xyz); + float ndl = max(dot(N, L), 0.0) * shadow; + float ndv = max(dot(N, V), 0.0); + float rim = pow(1.0 - ndv, 3.0) * 0.05; + return albedo * (ubo.ambient.rgb + ubo.lightColor.rgb * ndl) + vec3(rim); +} + +void applySolidMarch(float mode, float scale, float minLayers, float maxLayers, + vec3 lightTS, inout vec2 uv, inout vec3 N, inout float shadow, + inout float fragDepth, inout bool writeReliefDepth) { + vec3 camL = localFromWorld(vCameraPos); + vec3 fragL = localFromWorld(vWorldPos); + vec3 dirL = normalize(fragL - camL); + vec4 hit = ssdmMarchLocal(camL, dirL, minLayers, maxLayers); + bool front = isFrontFace(); + if (hit.z < 0.5) { + if (!front) + discard; + // Front miss → opaque chart fill (mortar). No FragDepth rewrite. + uv = clamp(vUV, 0.0, 1.0); + N = normalize(mat3(ubo.model) * heightNormalTS(uv, scale)); + shadow = 1.0; + return; + } + uv = clamp(hit.xy, 0.0, 1.0); + float h = heightAt(uv); + if (!front) { + // SilPOM: brick-cap limb only. SSDM: full continuous profile. + if (mode < 1.5 && h < 0.50) + discard; + vec3 hitLocal = vec3(uv.x * 2.0 - 1.0, uv.y * 2.0 - 1.0, hit.w); + fragDepth = fragDepthFromLocal(hitLocal); + writeReliefDepth = true; + } + N = normalize(mat3(ubo.model) * heightNormalTS(uv, scale)); + shadow = mix(1.0, selfShadow(uv, 1.0 - hit.w, lightTS, scale, + minLayers, maxLayers), 0.55); + // Mild contact darkening in mortar trenches (reads depth without soft pillows). + float trench = 1.0 - smoothstep(0.40, 0.85, h); + shadow *= mix(1.0, 0.72, trench); +} + +void main() { + float mode = pc.data[0]; + float scale = max(pc.data[1], 0.0); + float minLayers = max(pc.data[2], 1.0); + float maxLayers = max(pc.data[3], minLayers); + + vec3 Nw = normalize(vNormal); + vec3 V = normalize(vCameraPos - vWorldPos); + if (dot(Nw, V) < 0.0) + Nw = -Nw; + + if (mode >= 0.5) { + vec3 frontW = normalize(mat3(ubo.model) * vec3(0.0, 0.0, 1.0)); + if (dot(normalize(vNormal), frontW) < -0.35) + discard; + } + + mat3 TBN = makeTBN(Nw, vWorldPos, vUV); + vec3 viewTS = length(TBN[0]) > 1e-4 + ? normalize(transpose(TBN) * V) + : vec3(0.0, 0.0, 1.0); + vec3 lightTS = length(TBN[0]) > 1e-4 + ? normalize(transpose(TBN) * normalize(ubo.lightDirIntensity.xyz)) + : vec3(0.0, 0.0, 1.0); + + vec2 uv = vUV; + vec3 N = Nw; + float fragDepth = gl_FragCoord.z; + float shadow = 1.0; + bool writeReliefDepth = false; + + if (mode < 0.5) { + // Classic POM — parallax inside the chart, silhouette stays geometric. + float facing = clamp(abs(viewTS.z), 0.0, 1.0); + float pomAmt = smoothstep(0.16, 0.48, facing); + vec4 hit = pomHit(vUV, viewTS, scale * pomAmt, minLayers, maxLayers); + uv = clamp(mix(vUV, hit.xy, pomAmt), 0.0, 1.0); + vec3 nTS = heightNormalTS(uv, scale); + nTS = normalize(mix(vec3(0.0, 0.0, 1.0), nTS, pomAmt)); + N = normalize(TBN * nTS); + shadow = mix(1.0, selfShadow(uv, hit.z, lightTS, scale * pomAmt, + minLayers, maxLayers), 0.30 * pomAmt); + } else { + // SilPOM (1) and SSDM (2): shared solid heightfield march. + applySolidMarch(mode, scale, minLayers, maxLayers, lightTS, + uv, N, shadow, fragDepth, writeReliefDepth); + } + + if (writeReliefDepth) + gl_FragDepth = min(gl_FragCoord.z, fragDepth); + else + gl_FragDepth = gl_FragCoord.z; + + vec3 albedo = texture(albedoSampler, clamp(uv, 0.0, 1.0)).rgb * vTint.rgb * ubo.tint.rgb; + if (mode < 0.5) + albedo *= vec3(1.00, 0.94, 0.88); + else if (mode < 1.5) + albedo *= vec3(0.90, 1.00, 0.92); + else + albedo *= vec3(0.94, 0.96, 1.04); + + outColor = vec4(shadeLit(albedo, N, V, shadow), 1.0); +} diff --git a/examples/silpom-ssdm-compare/shaders/compare.frag.spv b/examples/silpom-ssdm-compare/shaders/compare.frag.spv new file mode 100644 index 00000000..2f0d76f4 Binary files /dev/null and b/examples/silpom-ssdm-compare/shaders/compare.frag.spv differ diff --git a/examples/spline-tube-lab/README.md b/examples/spline-tube-lab/README.md new file mode 100644 index 00000000..23af62d3 --- /dev/null +++ b/examples/spline-tube-lab/README.md @@ -0,0 +1,52 @@ +# Spline Tube Lab(样条管道与截面挤出) + +演示**不依赖任何源网格**的程序化生成:一条样条路径可以直接驱动管道、带状体与自定义 +截面挤出三种生成器,覆盖开放路径与闭合回路、逐点截面 / 旋转 / 分块(chunk break)、 +分布采样与沿路径行进(travel)坐标系。没有输入,启动即静态展示。 + +## 运行 + +```bash +cd examples/spline-tube-lab +../../build/linux-debug/src/engine/eve run +``` + +或: + +```bash +make run/-debug GAME=examples/spline-tube-lab +``` + +## 4 个变体 + +| 变体 | 生成器 | 路径与参数 | +|---|---|---| +| 开放管道 | `mesh.splineTube` | 4 点 Catmull-Rom 开放路径、半径 0.48、48 路径段 × 16 径向段、封盖 | +| 带状体 | `mesh.splineRibbon` | 带坡度的道路路径、宽 1.8、厚 0.28 | +| 自定义截面 | `mesh.splineExtrude` | U 形开口截面(`setNodeSplineProfile`),沿开放路径挤出 | +| 闭合回路 | `mesh.splineTube` | `applyShapePreset("circle", 16, 2.0, 0.0, 1.0)` 预置圆环、无缝闭合、半径 0.32 | + +细节: + +- 逐点轮廓与旋转:`setPointProfile` / `setPointRotation`(管道与道路倾斜)。 +- 分块:`setPointChunkBreak` 把一条样条切成多个 chunk,路径线用 `polylineResult` 分块绘制。 +- 分布与行进:`distributeResult(6, ...)` 取 6 个等距采样点及其朝向, + `travelResult` / `travelFrameResult` 取沿路径行进的坐标与 up/side 基向量。 + +成功标记: + +- 控制台打印 `SPLINE_TUBE_LAB_PASS variants=4 open=capped closed=seamless ...` +- 当前目录写出 `spline-tube-lab.png` + +## 文件 + +| 路径 | 说明 | +|---|---| +| `main.nut` | 全部逻辑:样条构造、四种生成器、路径线、相机、截图 | +| `config.nut` | 引擎 `config`:1100×720、`hotReload = true` | +| `spline-tube-lab.png` | 期望产出的画面(示例自带参考图) | + +## 契约 + +本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的 +帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。 diff --git a/examples/terrain-gallery/config.nut b/examples/terrain-gallery/config.nut new file mode 100644 index 00000000..9d92443d --- /dev/null +++ b/examples/terrain-gallery/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1280 + height = 720 + title = "EVEngine Terrain Gallery" + debug = false + hotReload = true +}; diff --git a/examples/terrain-preview/README.md b/examples/terrain-preview/README.md new file mode 100644 index 00000000..3e2efe41 --- /dev/null +++ b/examples/terrain-preview/README.md @@ -0,0 +1,53 @@ +# Terrain Preview — 直写交换链的 3D 地形预览与视觉审计抓帧 + +本示例在脚本内用 `procgen` 生成一块 257×257 高度图,经热力、水力与河流三种侵蚀后 +切分成 2×2 个地形区块,并直写交换链渲染完整的 3D 地形。除地表 splat 贴图外,它还按 +高度图与分层数据生成程序化河道与湖面水体,并在演示稳定后自动抓取一帧 PNG 供视觉审计。 + +## 运行 + +```bash +make run/-debug GAME=examples/terrain-preview +``` + +也可以进入示例目录后直接启动引擎: + +```bash +cd examples/terrain-preview && ../../build/linux-debug/src/engine/eve run +``` + +Windows 下引擎可执行文件为 `build/win32-debug/src/engine/eve.exe`。 + +## 演示内容 + +- 高度图生成:`procgen.newParams()` 配置 `setSize(257, 257)`、`setSeed(20260826)`, + 以及 `frequency`、`octaves`、`gain`、`ridge`、`warp`、`exponent`、`continent`、 + `island`、`coast` 等参数,再交给 `procgen.generateHeightmap(params)` 求值。 +- 三层侵蚀:`procgen.erodeTerrainThermal`、`procgen.erodeTerrainHydraulic`、 + `procgen.erodeTerrainFluvialAdvanced` 依次作用,随后 `procgen.analyzeTerrain` + 从高度图得到地表分层数据(layers),供后续区块与水体网格使用。 +- 地形区块:`procgen.buildTerrainChunk` 生成 2×2 个 128×128 区块, + `procgen.generateTerrainChunkMesh` 转成渲染网格,`procgen.generateTerrainSplatMap` + 生成反照率贴图,再由 `gfx.newTexture` 上传。 +- 材质:地表用 `procgen.createTerrainMaterialShader`,水体用 + `procgen.createTerrainWaterShader`;地表 `setMetallic(0.0)` / `setRoughness(0.92)`, + 水体则 `setMetallic(0.05)`~`0.08`、`setRoughness(0.20)`~`0.12`,比地表更光滑、略更反光。 +- 水系:`procgen.generateTerrainRiverMesh` 与 `procgen.generateTerrainLakeMesh` + 在高度图与分层数据上生成河道和湖面,非空时各自包成 `eve.Renderable3D`。 +- 相机与光照:`eve.Camera3D` 以 `setEye(62.0, 31.0, 64.0)`、`setTarget(22.4, 4.0, 22.4)`、 + `setFov(48.0)`、`setAmbient(...)` 俯视地形并 `setActive(true)`; + `gfx.setDirectionalLight` 与 `gfx.setBackgroundColor` 设定场景基调。 + +## 截图 + +`eve_render` 每帧调用 `gfx.clear()` 与 `gfx.render3D()` 并累计已渲染帧数。当 +`elapsed > 1.5`(约 1.5 秒)且 `renderedFrames >= 8` 时,脚本调用 +`gfx.saveFramePng("/private/tmp/evengine-terrain-preview.png")` 写出这一帧 PNG; +成功后打印 `terrain preview frame saved: /private/tmp/evengine-terrain-preview.png` +并把 `saved` 置为 `true`,因此整个进程只抓一帧。 + +## 相关文件 + +- `main.nut`:示例全部逻辑——高度图与侵蚀、区块网格与 splat 贴图、河流 / 湖泊、相机与抓帧。 +- `config.nut`:窗口配置,标题为 `EVEngine Terrain Preview`,尺寸 1000×700。 +- `README.md`:本文件。 diff --git a/examples/terrain-stamping/README.md b/examples/terrain-stamping/README.md new file mode 100644 index 00000000..2d5ebbaa --- /dev/null +++ b/examples/terrain-stamping/README.md @@ -0,0 +1,153 @@ +# Terrain stamping + +This scene compares a procedural source terrain on the left with the processed +terrain on the right. It uses the native rotated stamp, distance mask, contrast, +terrace, smooth and thermal kernels. Four mesh chunks per terrain share height +samples at their boundaries. All input data is generated locally; no Unity package +or downloaded art is needed. Colors use the existing terrain albedo bake and the +built-in lit mesh material, so this scene does not depend on runtime GLSL compilation. +The processed terrain also bakes the Pcg GTS snow and rain albedo branches over +the splat result. This validates their profile math and ordering; normal, +smoothness, mask and displacement outputs remain outside this albedo-only pass. + +Run the current engine with `eve run examples/terrain-stamping`. For an engine-owned +capture, add `--debug --mcp-port=7529` and call `eve_screenshot` after the +`TERRAIN_STAMPING_READY` message. The `pcg_validation` script table exposes readiness, +frame count and changed samples for integration checks. + +This is an integration scene for the implemented kernels, not the complete Pcg +world workflow. Authoring UI and world-scale orchestration are still being ported. +Numerical tests live in `test/terrain_stamp/`; rendering must also be +checked using the newly built host executable. + +`TERRAIN_COLLISION_MASK_PASS layers=1` evaluates an owned Pcg radius/layer collision-mask stack before stamping. +`TERRAIN_POLYGON_MASK_PASS nodes=3` rasterizes a closed Pcg-style PolyMask on the same real runtime path. +`TERRAIN_WORLD_BIOME_MASK_PASS entries=1` stores, resamples, invalidates, rejects stale data, and rebuilds one +stable-GUID world-biome baked mask. +`TERRAIN_GLOBAL_SPAWNER_MASK_PASS` feeds that scalar output through Pcg's image-mask transform, curve, and blend pass. +`TERRAIN_NOISE_MASK_PASS types=5` evaluates the deterministic native NoiseMask path with fractional fBm and warp. +`TERRAIN_SMOOTH_MASK_PASS passes=2` evaluates Pcg's horizontal and vertical Smooth ImageMask passes. + + +Startup also runs `validate-image-mask.nut` against real ImageData and owned procgen +heightmaps. `TERRAIN_IMAGE_ADAPTER_PASS` confirms RGBA8 quantization, RGBA32F row order, +and atomic rejection of nonfinite pixels/invalid settings. This requires the full +image module; the CPU-only tests exercise the separate scalar-plane kernel. + + +The stamp now executes through TerrainGenerationSession: reset baseline, record, +undo, redo, then copy the owned result to the terrain used for rendering. +`TERRAIN_SESSION_PASS operations=5 staged=1 persisted=1` marks the successful stamp/contrast/terrace/smooth/thermal +history and full replay. Thermal undo/redo restores sediment together with terrain; +both outputs are copied from the authoritative session before rendering. + + +The right terrain now includes a native grass detail layer: integer density, stable +PointSet placement, then direct GrassField foliage upload using the original static RGBA grass image, +a tangent normal map and a packed metallic/occlusion/thickness/smoothness mask. +`TERRAIN_GRASS_PASS` checks count preservation, empty resource selection and invalid-width +rollback. This loads the Pcg-authored texture documented in assets/SOURCE.md and translates the active +PW foliage shader behavior: distance alpha fade, cutoff, base tint, height snow, packed mask channels, +normal strength, shadowed directional light and thickness backlighting. + +Invalid RGBA32F texture input is rejected while preserving the existing grass field. + +For close inspection, call `pcg_validation.closeView()` through eve_eval before capturing. + +Grass instances use independent width and height ranges; zero-width upload is rejected without replacing the field. + +Terrain trees are exported into PointSet, rendered from one shared procedural tree mesh, and use +the backend-native tree wind shader. `pcg_validation.treeView()` selects the first tree; the existing +`freezeWind(seconds,enabled)` helper applies the same explicit snapshot to grass and trees. + +Startup also builds a two-tile owned height workspace, stamps across the shared seam, then performs +an all-tile undo/redo. `TERRAIN_MULTITILE_PASS tiles=2` verifies the runtime binding and transaction. +It also creates a Pcg-centered two-tile `TerrainWorldWorkspace`; `TERRAIN_WORLD_PASS` verifies stable +tile naming, unified history, script-driven flatten and spawn clearing, and complete-world undo. +`TERRAIN_SPAWN_PLAN_PASS rules=4 persisted=1 modifier=1 probe=1` snapshots, restores, then executes an owned terrain +modifier stamp, texture rule, detail rule, and reflection-probe rule in one stable-ID plan. + +`TERRAIN_BIOME_PRESET_PASS spawners=1 persisted=1 staged=1` saves and restores that Pcg-style preset, cancels one +partial private run, then executes its biome-active stack to one world publication +through one world transaction. +It also applies one shared detail window through an owned two-tile detail workspace and restores its +all-layer history; `TERRAIN_MULTIDETAIL_PASS tiles=2` verifies that Squirrel path. +The tree path repeats this with one shared RNG stream and renders the copied east-tile points; +`TERRAIN_MULTITREE_PASS points=4` verifies its transaction before the first present. +`TERRAIN_PROBE_PASS` verifies deterministic ReflectionProbe placement and the shared Reflection/Light probe contract. +`TERRAIN_PROBE_PROVIDER_PASS` verifies graphics-owned publication, a bounded real cubemap-face capture tick, +removal, and stable batch republishing. +`TERRAIN_WATER_FLOW_MAP_PASS` runs Pcg's separate legacy droplet tracer, axis flip, and in-place smoothing path +before rendering; it is intentionally distinct from the hydraulic flux/sediment simulation. +`TERRAIN_VELOCITY_FLOW_PASS` executes `HeightMap.FlowMap`'s fixed-step four-direction flux and normalized final +velocity magnitude as a third, separately validated water-derived scalar resource. +`TERRAIN_DERIVED_MAP_PASS` evaluates the separate `HeightMap.CurvatureMap` differential geometry and +`HeightMap.Aspect` orientation conventions before the render path starts. +`TERRAIN_TERRACE_REMOVAL_PASS` runs Pcg 4.2.2's classify, adaptive smooth, gradient-noise, directional-filter, +and Gaussian de-terracing pipeline with an explicit deterministic noise seed. +The splat path paints normalized texture weights across two owned tiles and restores all layers; +`TERRAIN_MULTISPLAT_PASS tiles=2` verifies its copy and undo/redo transaction. +`TERRAIN_SPLAT_ALBEDO_PASS pixels=1024` confirms that the three-layer packed texture result is uploaded as +RGBA8 and used by the processed terrain before the first present. +`TERRAIN_GTS_HEIGHT_BLEND_PASS layers=3 transition=0.18` confirms all three normalized splat weights are +recomputed from transformed material heights before the albedo and later GTS stages consume them. +`TERRAIN_GTS_PACKED_LAYERS_PASS layers=3 outputs=4` confirms real per-layer textures, UVs, normals, AO, +smoothness, geological strength and detail strength replace the former constant-color visualization path. +`TERRAIN_GTS_LAYER_PROJECTION_PASS triplanar=1 stochastic=1` confirms one layer uses the exact GTS +world-space three-plane projection and another uses its TriangleGrid/hash anti-tiling branch. +`TERRAIN_GTS_LAYER_DISPLACEMENT_PASS top=4 outputs=2` confirms the same packed layer set drives the GTS +top-four albedo-alpha displacement and tessellation pass before snow modifies those outputs. +`TERRAIN_GTS_GLOBAL_BLEND_PASS camera=137,96,151` confirms the shared near/far raster is generated from the +actual demonstration camera using the GTS squared-distance formula. +`TERRAIN_GTS_COLORMAP_PASS near=0.22 far=0.72` confirms the source-order near/far colormap blend. +`TERRAIN_GTS_GEOLOGICAL_PASS near=8 far=19 outputs=2` confirms height-axis near/far geological color and +packed tangent-normal overlays are committed together before weather. +`TERRAIN_GTS_DETAIL_PASS near=7 far=23 outputs=3` confirms near/far detail normals, layer-weighted +micro-shadowing, and the detail raster subsequently consumed by snow shading. +`TERRAIN_GTS_WEATHER_PASS snow=1 rain=1 pixels=1024` confirms that the same image then receives height/slope snow +and bounded-altitude rain darkening before upload. +`TERRAIN_GTS_VARIATION_PASS scales=3 intensity=0.72` confirms that three world-space repeating samples modulate +the result after weather, matching the GTS material order. +`TERRAIN_GTS_PBR_PASS outputs=4 time=2` executes the packed mask, renderer-ready tangent normal, displacement and tessellation +branches with an explicit deterministic rain-animation time. +`TERRAIN_GTS_MATERIAL_PASS packedNormalMask=1 parallax=1` uploads the packed tangent normal/AO/smoothness +texture and displacement-derived height texture to every processed-terrain renderable. +`TERRAIN_TEXTURE_MASK_PASS layer=1` confirms the selected Pcg terrain-texture weight is copied into a scalar +mask source before the same splatmap is rendered. +The game-object path executes one multi-resource rule through a two-tile owned workspace with a shared +Pcg RNG stream and seam collision domain; `TERRAIN_MULTIOBJECT_PASS` verifies copy and undo/redo. + + +Grass now uses the PW wind main/branch/leaf deformation with an explicit update clock. +The same update owns a looping, listener-relative procedural wind-noise Source and applies Pcg WindManager's +source-specific volume transition through `VegetationWindAudioState`; `TERRAIN_WIND_PASS audio=0.2 looping=1` +checks the real audio object before the first presented frame. +For repeatable comparisons, call `pcg_validation.freezeWind(2.0, true)` or +`pcg_validation.freezeWind(0.0, false)` after `closeView()`. Strength 1 intentionally +exercises large source deformation. The example uses typed VegetationWindState/Profile, checked advanceVegetationWind and +applyGrassWind operations. TERRAIN_WIND_PASS verifies invalid distance rejection at startup. + +`TERRAIN_NEIGHBORHOOD_FILTER_PASS` runs Pcg-compatible `HeightMap.DeNoise`, `GrowEdges`, and +`ShrinkEdges` over the generated terrain while preserving their order-dependent in-place traversal. +`TERRAIN_HEIGHTMAP_FILTER_PASS` covers Pcg's four-neighbor smooth, radius-five sliding-window smooth, +and odd-square in-place convolution through the public Squirrel API. +`TERRAIN_SLOPE_QUANTIZE_PASS` validates Pcg's normalized `SlopeMap` gradient and scalar `Quantize` +midpoint rounding through the same runtime path. +`TERRAIN_HEIGHTMAP_ARITHMETIC_PASS` runs scalar and raster arithmetic, clamping, mismatched-raster +resampling, and masked lerp through the public bindings. +`TERRAIN_HEIGHTMAP_TRANSFORM_PASS` runs the unmasked Pcg normalize and direct-exponent power transforms; +the same API also exposes invert and source-formula contrast. +`TERRAIN_HEIGHTMAP_COPY_PASS` covers conditional Copy and resampled CopyClamped with a smaller output raster. +`TERRAIN_HEIGHTMAP_FLIP_PASS` verifies Pcg's rectangular matrix-transpose meaning of `HeightMap.Flip`. +`TERRAIN_HEIGHTMAP_MEASURE_PASS` reads min, max, average, and scanner edge base level from current samples. +`TERRAIN_TERRACE_QUANTIZE_PASS` executes Pcg's multi-terrace curve overload using native sampled curve rows. +`TERRAIN_SLOPE_QUERY_PASS` executes all three Pcg point-slope formulas and reports their distinct units. +`TERRAIN_HEIGHTMAP_WRITE_PASS` covers clamped fill, safe-border writes, Pcg row/column orientation, and reset. +`TERRAIN_PCG_RAW_PASS` decodes headerless Pcg RAW8 bytes through `Procgen.loadTerrainBytes`; RAW16 little- +and big-endian paths share the tested native decoder. +`TERRAIN_LEGACY_HYDRAULIC_PASS` runs the separate `HeightMap.ErodeHydraulic` CPU loop with periodic rain, +persistent four-way flux, hardness-controlled dissolution and atomic terrain/sediment publication. +`TERRAIN_LEGACY_THERMAL_PASS` runs both active legacy CPU erosion variants: synchronous cardinal +redistribution and ordered steepest-neighbor transfer with a resampled hardness map. +`TERRAIN_HEIGHTMAP_ACCESS_PASS` verifies border-clamped integer reads, Pcg normalized bilinear reads, +data presence and two-dimensional power-of-two classification. diff --git a/examples/terrain-stamping/assets/PW_Grass_Terrain_01_D.png b/examples/terrain-stamping/assets/PW_Grass_Terrain_01_D.png new file mode 100644 index 00000000..cf69cb67 Binary files /dev/null and b/examples/terrain-stamping/assets/PW_Grass_Terrain_01_D.png differ diff --git a/examples/terrain-stamping/assets/SOURCE.md b/examples/terrain-stamping/assets/SOURCE.md new file mode 100644 index 00000000..b1d402fa --- /dev/null +++ b/examples/terrain-stamping/assets/SOURCE.md @@ -0,0 +1,14 @@ +# Grass texture provenance + +PW_Grass_Terrain_01_D.png is copied unchanged from the user-supplied Pcg Pro VS +Unity 6 v4.2.2 package, nested Pcg Pro Assets and Biomes.unitypackage. + +- Unity GUID: 0ba274ba3ae7d3a4482573ef44ef9a30 +- Original path: Assets/Procedural Worlds/Packages - Install/Pcg Pro Assets and Biomes/Content Resources/Terrain Details/Legacy/PW_Grass_Terrain_01_D.png +- Bytes: 1390217 +- SHA-256: 6e3dff3fe084683753678393d340bcc679a913c8920eb713d37f72c09e976d6d +- Dimensions: 1024 by 1024, 8-bit RGBA PNG. + +This is third-party source artwork, not engine-authored art. Its original licensing +continues to apply. No recoloring, resizing, alpha reconstruction or frame synthesis +was performed. The native renderer treats it as one static image with original alpha. diff --git a/examples/terrain-stamping/config.nut b/examples/terrain-stamping/config.nut new file mode 100644 index 00000000..d58258a7 --- /dev/null +++ b/examples/terrain-stamping/config.nut @@ -0,0 +1,6 @@ +config = { + title = "Terrain Stamping - source (left), processed (right)", + width = 1200, + height = 760, + hotReload = true, +} diff --git a/examples/terrain-stamping/main.nut b/examples/terrain-stamping/main.nut new file mode 100644 index 00000000..62dc55b7 --- /dev/null +++ b/examples/terrain-stamping/main.nut @@ -0,0 +1,940 @@ +// Integration scene for native terrain generation and grass using the documented source texture. +dofile("validate-image-mask.nut"); +local retained = []; +local camera = null; +local terrainGrass = null; +local windSeconds = 0.0; +local windState = null; +local windProfile = null; +local windAudioState = null; +local windAudioSource = null; +local waterSystemState = null; +local waterSystemSettings = null; +local waterSceneConditions = null; +local terrainWater = null; +local terrainWaterEntity = null; +local treeWindShader = null; +local treeWindProfile = null; +local sharedTreeMesh = null; +local windPaused = false; +local gtsDetailGreyscale = null; +local gtsGlobalBlend = null; +local gtsHeightSplat = null; +local gtsGeoStrength = null; +local gtsLayerDetailStrength = null; +local gtsLayerDisplacement = null; +local gtsLayerTessellation = null; +pcg_validation <- { ready = false, frames = 0, changed = 0, sediment = 0.0 }; + +function retain(value) { + if (value == null) throw "terrain-stamping: required resource is null"; + retained.append(value); + return value; +} + +function requireResult(result, operation) { + if (!result.ok) throw "terrain-stamping: " + operation + " failed"; + return result.value; +} + +function heightmap(width, height) { + return retain(requireResult(procgen.newHeightmap(width, height), "newHeightmap")); +} + +function setupWaterSystem() { + waterSystemState=retain(eve.WaterSystemState());waterSystemSettings=retain(eve.WaterSystemSettings()); + waterSceneConditions=retain(eve.WaterSceneConditions()); + waterSystemState.seaLevel=3.5;waterSystemSettings.refreshRate=0.5; + waterSystemSettings.infiniteMode=true;waterSystemSettings.autoRefresh=true; + waterSystemSettings.ignoreSceneConditions=true;waterSystemSettings.setAutoUpdateMode(0); + waterSceneConditions.setSunColor(1.0,0.92,0.78);waterSceneConditions.setSunDirection(35.0,120.0,0.0); + waterSceneConditions.sunIntensity=1.25;waterSceneConditions.sunAvailable=true; + requireResult(eve.initializeWaterSystem(waterSystemState,waterSystemSettings,waterSceneConditions,7),"water initialize"); + local refresh=requireResult(eve.advanceWaterSystem(waterSystemState,waterSystemSettings,waterSceneConditions, + 137.0,151.0,0.6,9),"water advance"); + if(!refresh || waterSystemState.positionX!=137.0 || waterSystemState.positionY!=3.5 || + waterSystemState.positionZ!=151.0 || waterSystemState.refreshRevision!=1) throw "water controller mismatch"; + local changed=requireResult(eve.updateWaterSeaLevel(waterSystemState,4.25,true),"water sea level"); + if(!changed || waterSystemState.positionY!=4.25 || waterSystemState.refreshRevision!=2) + throw "water sea level mismatch"; + terrainWater=retain(gfx.newWater()); + local meshSettings=eve.WaterMeshSettings();meshSettings.setType(1); + meshSettings.sizeX=20.0;meshSettings.sizeY=1.0;meshSettings.sizeZ=20.0; + meshSettings.densityX=12.0;meshSettings.densityY=12.0;meshSettings.height=0.0; + local triangles=requireResult(eve.calculateWaterMeshTriangles(meshSettings),"water polygon count"); + local vertices=requireResult(terrainWater.createProceduralMesh(meshSettings),"water circle mesh"); + if(vertices!=37 || triangles!=54) throw "water circle topology mismatch"; + terrainWater.setWaveAmplitude(0.22);terrainWater.setRippleAmplitude(0.3); + requireResult(terrainWater.setWaveDirectionAngle(52.5),"water wave direction"); + if(terrainWater.getWaveDirectionAngle()!=52.5) throw "water wave direction mismatch"; + terrainWater.setReflectionIntensity(0.72);terrainWater.setSunIntensity(0.65); + local depthGradient=eve.WaterDepthGradient(); + requireResult(depthGradient.addColorStop(0.0,0.10,0.68,0.78),"water shallow gradient"); + requireResult(depthGradient.addColorStop(0.42,0.02,0.30,0.48),"water middle gradient"); + requireResult(depthGradient.addColorStop(1.0,0.005,0.035,0.09),"water deep gradient"); + requireResult(depthGradient.addAlphaStop(0.0,0.42),"water shallow alpha"); + requireResult(depthGradient.addAlphaStop(1.0,0.94),"water deep alpha"); + local rampPixels=requireResult(terrainWater.setDepthGradient(depthGradient,16),"water depth gradient"); + if(rampPixels!=256) throw "water depth gradient size mismatch"; + local reflectionSettings=eve.WaterPlanarReflectionSettings(); + reflectionSettings.textureResolution=600;reflectionSettings.setResolutionMultiplier(2); + reflectionSettings.clipPlaneOffset=0.2;reflectionSettings.shadows=true; + reflectionSettings.enableRenderDistance=true;reflectionSettings.customRenderDistance=275.0; + local reflectionInput=eve.WaterPlanarReflectionInput();reflectionInput.waterPlaneY=4.25; + reflectionInput.renderScale=0.8;reflectionInput.setCameraPosition(70.0,18.0,56.0); + reflectionInput.setCameraForward(0.0,-0.6,-0.8); + local reflectionPlan=eve.WaterPlanarReflectionPlan(); + requireResult(eve.buildWaterPlanarReflectionPlan(reflectionPlan,reflectionSettings,reflectionInput),"water planar reflection"); + if(!reflectionPlan.shouldRender || reflectionPlan.textureWidth!=158 || + abs(reflectionPlan.getCameraY()-(-8.7))>0.001 || reflectionPlan.getLayerDistance(31)!=275.0) + throw "water planar reflection plan mismatch"; + local underwaterSettings=eve.WaterUnderwaterSettings();underwaterSettings.causticTextureCount=16; + underwaterSettings.setFogColorMultiplier(0.1,-1.0,0.0); + local underwaterInput=eve.WaterUnderwaterInput();underwaterInput.seaLevel=4.25;underwaterInput.cameraY=8.0; + underwaterInput.setMainLightColor(1.0,0.9,0.8); + local underwaterState=eve.WaterUnderwaterState(),underwaterOutput=eve.WaterUnderwaterOutput(); + local underwaterDepth=eve.UnderwaterColorGradient(),underwaterTime=eve.UnderwaterColorGradient(); + local underwaterExposure=eve.UnderwaterScalarCurve(),underwaterPostColor=eve.UnderwaterColorGradient(); + requireResult(underwaterDepth.addStop(0.0,0.4,0.7,1.0),"underwater shallow fog"); + requireResult(underwaterDepth.addStop(1.0,0.03,0.08,0.18),"underwater deep fog"); + requireResult(underwaterTime.addStop(0.0,0.1,0.2,0.4),"underwater dawn fog"); + requireResult(underwaterTime.addStop(1.0,0.8,0.5,0.2),"underwater day fog"); + requireResult(underwaterExposure.addKey(0.0,-0.4),"underwater night exposure"); + requireResult(underwaterExposure.addKey(1.0,0.1),"underwater day exposure"); + requireResult(underwaterPostColor.addStop(0.0,0.4,0.6,0.8),"underwater night filter"); + requireResult(underwaterPostColor.addStop(1.0,0.8,0.95,1.0),"underwater day filter"); + requireResult(eve.advanceWaterUnderwaterEffects(underwaterState,underwaterOutput,underwaterSettings, + underwaterInput,underwaterDepth,underwaterTime, + underwaterExposure,underwaterPostColor),"underwater surface state"); + underwaterInput.cameraY=-20.75;underwaterInput.causticTicks=18; + requireResult(eve.advanceWaterUnderwaterEffects(underwaterState,underwaterOutput,underwaterSettings, + underwaterInput,underwaterDepth,underwaterTime, + underwaterExposure,underwaterPostColor),"underwater submerged state"); + if(!underwaterOutput.entered || !underwaterOutput.loopAudio || !underwaterOutput.particles || + underwaterOutput.causticFrame!=2 || abs(underwaterOutput.depth01-0.25)>0.001) + throw "underwater effects state mismatch"; + terrainWaterEntity=retain(eve.Renderable3D());terrainWaterEntity.setMesh(terrainWater.getMesh()); + terrainWaterEntity.setShader(terrainWater.getShader());terrainWaterEntity.setPosition(70.0,4.25,32.0); + terrainWaterEntity.setNormalTexture(terrainWater.getDepthGradientTexture()); + terrainWaterEntity.setReceiveShadow(false);terrainWaterEntity.setCastShadow(false); + print("TERRAIN_WATER_SYSTEM_PASS sea=4.25 infinite=1 refresh=2 mesh=circle vertices=37 triangles=54 ramp=16 planar=158 underwater=1 caustic=2\n"); +} + +function applyGtsColorMap(image) { + local colorMap=retain(eve.Image().newEmptyImageData(32,32,"RGBA8")); + for(local z=0;z<32;++z) for(local x=0;x<32;++x) { + local farBlend=x/31.0;colorMap.setPixel(x,z,0.16+0.20*farBlend,0.38+0.12*farBlend,0.12,0.42); + } + local settings=eve.GtsColorMapSettings();settings.alphaIntensity=1.15;settings.colorIntensity=1.1; + settings.nearIntensity=0.22;settings.farIntensity=0.72; + requireResult(eve.bakeGtsColorMapAlbedo(image,colorMap,gtsGlobalBlend,settings),"GTS colormap albedo"); + print("TERRAIN_GTS_COLORMAP_PASS near=0.22 far=0.72\n"); +} + +function applyGtsMacroVariation(image) { + local map=retain(eve.Image().newEmptyImageData(4,4,"RGBA8")); + for(local z=0;z<4;++z) for(local x=0;x<4;++x) map.setPixel(x,z,0.16+0.18*((x+z)%3),0.0,0.0,1.0); + local settings=eve.GtsMacroVariationSettings();settings.sizeA=13.0;settings.sizeB=31.0;settings.sizeC=67.0;settings.intensity=0.72; + requireResult(eve.bakeGtsMacroVariationAlbedo(image,map,settings,78.0,0.0,2.0,2.0),"GTS macro variation"); + print("TERRAIN_GTS_VARIATION_PASS scales=3 intensity=0.72\n"); +} + +function applyGtsGeological(albedo, packedNormal, heights) { + local color=retain(eve.Image().newEmptyImageData(1,4,"RGBA8")); + color.setPixel(0,0,0.20,0.16,0.10,1.0);color.setPixel(0,1,0.44,0.35,0.20,1.0); + color.setPixel(0,2,0.16,0.21,0.11,1.0);color.setPixel(0,3,0.56,0.50,0.34,1.0); + local normal=retain(eve.Image().newEmptyImageData(1,4,"RGBA8")); + normal.setPixel(0,0,0.0,0.46,0.0,0.53);normal.setPixel(0,1,0.0,0.55,0.0,0.48); + normal.setPixel(0,2,0.0,0.43,0.0,0.56);normal.setPixel(0,3,0.0,0.52,0.0,0.46); + local settings=eve.GtsGeologicalSettings();settings.enabled=true;settings.nearStrength=0.12; + settings.nearNormalStrength=0.22;settings.nearScale=8.0;settings.farStrength=0.08; + settings.farNormalStrength=0.14;settings.farScale=19.0;settings.farOffset=0.17; + requireResult(eve.bakeGtsGeologicalSurface(albedo,packedNormal,heights,gtsGeoStrength,gtsGlobalBlend,color,normal,settings,0.0),"GTS geological surface"); + print("TERRAIN_GTS_GEOLOGICAL_PASS near=8 far=19 outputs=2\n"); +} + +function applyGtsDetail(albedo, packedNormal) { + local map=retain(eve.Image().newEmptyImageData(4,4,"RGBA8")); + for(local mz=0;mz<4;++mz) for(local mx=0;mx<4;++mx) { + map.setPixel(mx,mz,0.40+0.07*((mx+mz)%3),0.44+0.06*((mx*2+mz)%3),0.0,1.0); + } + local settings=eve.GtsDetailNormalSettings();settings.nearTiling=7.0;settings.nearStrength=0.34; + settings.farTiling=23.0;settings.farStrength=0.20; + gtsDetailGreyscale=heightmap(32,32); + requireResult(eve.bakeGtsDetailSurface(albedo,packedNormal,gtsDetailGreyscale,gtsLayerDetailStrength,gtsGlobalBlend,map,settings,78.0,0.0,2.0,2.0),"GTS detail surface"); + print("TERRAIN_GTS_DETAIL_PASS near=7 far=23 outputs=3\n"); +} + +function applyGtsLayerHeightBlend(input, vegetationHeight, rockHeight) { + local sandHeight=heightmap(32,32); + for(local z=0;z<32;++z) for(local x=0;x<32;++x) sandHeight.setHeight(x,z,0.58-0.28*vegetationHeight.height(x,z)); + local layers=eve.GtsHeightBlendSet(); + requireResult(layers.addLayer(sandHeight,1.0,1.0,0.0),"GTS sand height layer"); + requireResult(layers.addLayer(vegetationHeight,1.15,1.0,0.03),"GTS vegetation height layer"); + requireResult(layers.addLayer(rockHeight,0.8,1.2,0.05),"GTS rock height layer"); + gtsHeightSplat=eve.TerrainSplatmap(); + requireResult(eve.applyGtsHeightBlend(gtsHeightSplat,input,layers,0.18),"GTS layer height blend"); + print("TERRAIN_GTS_HEIGHT_BLEND_PASS layers=3 transition=0.18\n"); +} + +function bakeGtsLayerTextures(splat, albedo, normal, heights) { + local layers=eve.GtsPackedLayerSet(); + local colors=[[0.52,0.38,0.20],[0.18,0.48,0.16],[0.42,0.44,0.46]]; + for(local i=0;i<3;++i) { + local a=retain(eve.Image().newEmptyImageData(2,2,"RGBA8")); + local n=retain(eve.Image().newEmptyImageData(2,2,"RGBA8")); + for(local z=0;z<2;++z) for(local x=0;x<2;++x) { + local grain=0.88+0.12*((x+z+i)%2);a.setPixel(x,z,colors[i][0]*grain,colors[i][1]*grain,colors[i][2]*grain,0.3+0.25*i); + n.setPixel(x,z,0.47+0.04*x,0.47+0.04*z,0.72+0.08*i,0.24+0.22*i); + } + local s=eve.GtsPackedLayerSettings();s.tileSizeX=6.0+5.0*i;s.tileSizeZ=7.0+4.0*i; + s.normalStrength=0.35+0.15*i;s.geoAmount=0.25+0.3*i;s.detailAmount=0.45+0.2*i; + s.displacementContrast=0.8+0.1*i;s.displacementBrightness=0.10+0.03*i;s.tessellationAmount=8.0+4.0*i; + if(i==1)s.stochastic=true;if(i==2){s.triPlanar=true;s.triPlanarSizeX=0.8;s.triPlanarSizeZ=1.2;} + requireResult(layers.addLayer(a,n,s),"GTS packed layer"); + } + gtsGeoStrength=heightmap(32,32);gtsLayerDetailStrength=heightmap(32,32); + requireResult(eve.bakeGtsPackedLayers(albedo,normal,gtsGeoStrength,gtsLayerDetailStrength,heights,splat,layers,78.0,0.0,0.0,2.0,2.0),"GTS packed layers"); + print("TERRAIN_GTS_PACKED_LAYERS_PASS layers=3 outputs=4\n"); + print("TERRAIN_GTS_LAYER_PROJECTION_PASS triplanar=1 stochastic=1\n"); + gtsLayerDisplacement=heightmap(32,32);gtsLayerTessellation=heightmap(32,32); + requireResult(eve.bakeGtsPackedLayerDisplacement(gtsLayerDisplacement,gtsLayerTessellation,heights,splat,layers,137.0,96.0,151.0,1.0,78.0,0.0,0.0,2.0,2.0),"GTS layer displacement"); + print("TERRAIN_GTS_LAYER_DISPLACEMENT_PASS top=4 outputs=2\n"); +} + +function showTerrain(heights, offset, splatAlbedo=null, packedNormalTexture=null, heightTexture=null) { + local layers = retain(procgen.analyzeTerrain(heights, 200.0, 0.12, 0.4)); + for (local y=0; y<2; ++y) for (local x=0; x<2; ++x) { + local chunk = retain(procgen.buildTerrainChunk(heights, layers, x*32, y*32, + 32, 32, 0, 1.0, 28.0, 0.0)); + local mesh = retain(procgen.generateTerrainChunkMesh(chunk, gfx)); + local image = splatAlbedo!=null ? splatAlbedo : retain(procgen.generateTerrainAlbedoMap(chunk)); + local texture = retain(gfx.newTexture(image, false, false)); + local entity = retain(eve.Renderable3D()); + entity.setMesh(mesh); + entity.setTexture(texture); + if(packedNormalTexture!=null) { + entity.setNormalTexture(packedNormalTexture);entity.setPackedNormalMask(true); + } + if(heightTexture!=null) { entity.setHeightTexture(heightTexture);entity.setParallax(0.045,8.0,24.0); } + entity.setTint(1.0, 1.0, 1.0, 1.0); + entity.setMetallic(0.0); + entity.setRoughness(0.9); + entity.setPosition(offset+x*32.0, 0.0, y*32.0); + } +} + +function showTree(mesh, x, y, z, yaw, scaleX, scaleY, scaleZ, red, green, blue) { + local renderable=retain(eve.Renderable3D()); + renderable.setMesh(mesh); + if(treeWindShader!=null) renderable.setShader(treeWindShader); + renderable.setPosition(x,y,z);renderable.setYaw(yaw); + renderable.setScale(scaleX,scaleY,scaleZ); + renderable.setTint(red,green,blue,1.0);renderable.setRoughness(0.88);renderable.setCastShadow(true); +} + +function buildTerrainTreeMesh() { + local paramsResult=procgen.newParams(); + if(!paramsResult.ok) throw "terrain-stamping: tree parameter creation failed"; + local params=paramsResult.value; + params.setSeed(73);params.setString("style","lowpoly");params.setString("branchAlgorithm","weberPenn"); + params.setString("leafMode","clusters");params.setFloat("leafDensity",0.45);params.setFloat("height",5.5); + params.setFloat("crownRadius",1.8);params.setInt("branchLevels",2);params.setInt("branchCount",5); + params.setFloat("clusterSize",0.28);params.setFloat("clusterSeparation",0.58);params.setFloat("clusterLeafScale",0.78); + params.setInt("clusterPlanes",8);params.setInt("clusterLeaves",20);params.setInt("clusterLimit",72); + local generated=procgen.generateMesh("mesh.tree",params,gfx); + if(!generated.ok) throw "terrain-stamping: tree mesh failed"; + retained.append(generated.value); + sharedTreeMesh=generated.value; +} + +function setupTerrainTreeWind(bendFactor) { + treeWindShader=retain(gfx.newTreeWindShader());treeWindProfile=retain(eve.TreeWindProfile()); + treeWindProfile.setDimensions(3.6,5.5);treeWindProfile.bendFactor=bendFactor; +} + +function setupTerrainCamera(treePoints) { + camera=retain(eve.Camera3D()); + camera.setEye(137.0,96.0,151.0);camera.setTarget(71.0,5.0,32.0);camera.setUp(0.0,1.0,0.0); + camera.setFov(48.0);camera.setClipPlanes(0.1,500.0);camera.setAmbient(0.3,0.34,0.4);camera.setActive(true); + pcg_validation.closeView <- function() { camera.setEye(112.0,10.0,73.0);camera.setTarget(108.0,5.0,54.0); }; + pcg_validation.treeView <- function() { + local x=treePoints.getX(0),y=treePoints.getY(0),z=treePoints.getZ(0); + camera.setEye(x+11.0,y+7.0,z+11.0);camera.setTarget(x,y+3.0,z); + }; +} + +function setupTerrainWind() { + windState=retain(eve.VegetationWindState());windProfile=retain(eve.VegetationWindProfile()); + requireResult(eve.initializeVegetationWind(windState,1.0,0.0,0.2,1.0),"instant wind initialization"); + pcg_validation.windTime <- function(seconds, enabled) { + windState.setDirection(1.0,-0.5,0.2);windState.strength=1.0;windState.phase=seconds*0.1; + windProfile.enabled=enabled; + requireResult(eve.applyGrassWind(terrainGrass.getShader(),windState,windProfile,seconds),"wind snapshot"); + if(treeWindShader!=null) requireResult(eve.applyTreeWind(treeWindShader,windState,windProfile,treeWindProfile,seconds),"tree wind snapshot"); + }; + pcg_validation.freezeWind <- function(seconds, enabled) { windPaused=true;pcg_validation.windTime(seconds,enabled); }; + pcg_validation.windTime(0.0,true); + windProfile.maximumDistance=0.0; + local badWind=eve.applyGrassWind(terrainGrass.getShader(),windState,windProfile,0.0); + if(badWind.ok) throw "invalid wind accepted"; + windProfile.maximumDistance=100.0; + local windPcm=retain(eve.Sound().newSoundDataFromFile("wind.wav")); + windAudioSource=retain(eve.Audio().newSource(windPcm)); + windAudioSource.setLooping(true);windAudioSource.setRelative(true); + windAudioSource.setAttenuationDistances(1.0,100000.0);windAudioSource.setVolume(0.0);windAudioSource.play(); + windAudioState=retain(eve.VegetationWindAudioState()); + requireResult(eve.advanceVegetationWindAudio(windAudioState,1.0,5.0,1.0,true,true),"wind audio initialize"); + windAudioSource.setVolume(windAudioState.volume); + if(!windAudioSource.isLooping() || !windAudioSource.isPlaying() || windAudioSource.getVolume()!=0.2) + throw "wind audio source mismatch"; + print("TERRAIN_WIND_PASS audio="+windAudioSource.getVolume()+" looping=1\n"); +} + +function validateTerraceRemoval(source) { + local classes=heightmap(source.getWidth(),source.getHeight()); + local output=heightmap(source.getWidth(),source.getHeight()); + local settings=eve.TerrainTerraceRemovalSettings(); + settings.noiseSeed=73;settings.perlinStrength=0.002;settings.terrainWorkflow=true; + requireResult(eve.analyzeTerrainTerraces(classes,source,settings),"terrace classification"); + local changed=requireResult(eve.removeTerrainTerraces(output,source,classes,settings),"terrace removal"); + if(changed<=0) throw "terrace removal produced no filtered samples"; + print("TERRAIN_TERRACE_REMOVAL_PASS changed="+changed+"\n"); +} + +function validateVelocityFlow(source) { + local output=heightmap(source.getWidth(),source.getHeight()); + requireResult(eve.generateTerrainVelocityFlowMap(output,source,6),"heightmap velocity flow"); + local peak=0.0; + for(local y=0;ypeak)peak=value; + } + if(peak<0.999) throw "velocity flow normalization mismatch"; + print("TERRAIN_VELOCITY_FLOW_PASS peak="+peak+"\n"); +} + +function validateDerivedMaps(source) { + local curvature=heightmap(source.getWidth(),source.getHeight()); + local aspect=heightmap(source.getWidth(),source.getHeight()); + requireResult(eve.generateTerrainHeightmapCurvature(curvature,source,0),"heightmap average curvature"); + requireResult(eve.generateTerrainHeightmapAspect(aspect,source,0),"heightmap aspect"); + local centerCurvature=curvature.height(32,32),centerAspect=aspect.height(32,32); + if(centerCurvature<0.0 || centerCurvature>1.0 || centerAspect<0.0 || centerAspect>1.0) + throw "derived heightmap range mismatch"; + print("TERRAIN_DERIVED_MAP_PASS curvature="+centerCurvature+" aspect="+centerAspect+"\n"); +} + +function validateNeighborhoodFilters(source) { + local denoised=heightmap(source.getWidth(),source.getHeight()); + local grown=heightmap(source.getWidth(),source.getHeight()); + local shrunk=heightmap(source.getWidth(),source.getHeight()); + requireResult(eve.filterTerrainHeightmapNeighborhood(denoised,source,1,0),"heightmap denoise"); + requireResult(eve.filterTerrainHeightmapNeighborhood(grown,source,1,1),"heightmap grow edges"); + requireResult(eve.filterTerrainHeightmapNeighborhood(shrunk,source,1,2),"heightmap shrink edges"); + print("TERRAIN_NEIGHBORHOOD_FILTER_PASS denoise="+denoised.height(32,32)+ + " grow="+grown.height(32,32)+" shrink="+shrunk.height(32,32)+"\n"); +} + +function validateHeightmapFilters(source) { + local smooth=heightmap(source.getWidth(),source.getHeight()); + local radius=heightmap(source.getWidth(),source.getHeight()); + local convolved=heightmap(source.getWidth(),source.getHeight()); + local kernel=heightmap(3,3); + for(local y=0;y<3;++y)for(local x=0;x<3;++x)kernel.setHeight(x,y,1.0); + requireResult(eve.smoothTerrainHeightmap(smooth,source,2),"heightmap smooth"); + requireResult(eve.smoothTerrainHeightmapRadius(radius,source,5),"heightmap smooth radius"); + requireResult(eve.convolveTerrainHeightmap(convolved,source,kernel),"heightmap convolve"); + print("TERRAIN_HEIGHTMAP_FILTER_PASS smooth="+smooth.height(32,32)+ + " radius="+radius.height(32,32)+" convolve="+convolved.height(32,32)+"\n"); +} + +function validateSlopeAndQuantize(source) { + local slope=heightmap(source.getWidth(),source.getHeight()); + local quantized=heightmap(source.getWidth(),source.getHeight()); + requireResult(eve.generateTerrainHeightmapSlope(slope,source),"heightmap slope"); + requireResult(eve.quantizeTerrainHeightmap(quantized,source,0.025),"heightmap quantize"); + print("TERRAIN_SLOPE_QUANTIZE_PASS slope="+slope.height(32,32)+ + " quantized="+quantized.height(32,32)+"\n"); +} + +function validateHeightmapArithmetic(source) { + local scalar=heightmap(source.getWidth(),source.getHeight()); + local raster=heightmap(source.getWidth(),source.getHeight()); + local blended=heightmap(source.getWidth(),source.getHeight()); + local mask=heightmap(1,1);mask.setHeight(0,0,0.35); + requireResult(eve.applyTerrainHeightmapScalarArithmetic(scalar,source,0.04,0,true,0.0,1.0),"heightmap scalar add"); + requireResult(eve.applyTerrainHeightmapRasterArithmetic(raster,scalar,source,1,true,0.0,1.0),"heightmap raster subtract"); + requireResult(eve.lerpTerrainHeightmap(blended,source,scalar,mask),"heightmap lerp"); + print("TERRAIN_HEIGHTMAP_ARITHMETIC_PASS scalar="+scalar.height(32,32)+ + " raster="+raster.height(32,32)+" lerp="+blended.height(32,32)+"\n"); +} + +function validateHeightmapTransforms(source) { + local normalized=heightmap(source.getWidth(),source.getHeight()); + local powered=heightmap(source.getWidth(),source.getHeight()); + requireResult(eve.transformTerrainHeightmap(normalized,source,1,0.0),"heightmap normalize"); + requireResult(eve.transformTerrainHeightmap(powered,source,2,1.5),"heightmap power"); + print("TERRAIN_HEIGHTMAP_TRANSFORM_PASS normalized="+normalized.height(32,32)+ + " powered="+powered.height(32,32)+"\n"); +} + +function validateHeightmapCopy(source) { + local copied=heightmap(33,33); + local clamped=heightmap(33,33); + for(local y=0;y<33;++y)for(local x=0;x<33;++x)copied.setHeight(x,y,0.18); + requireResult(eve.copyTerrainHeightmap(copied,source,2),"heightmap copy greater"); + requireResult(eve.copyTerrainHeightmapClamped(clamped,source,0.14,0.20),"heightmap copy clamped"); + print("TERRAIN_HEIGHTMAP_COPY_PASS conditional="+copied.height(16,16)+ + " clamped="+clamped.height(16,16)+"\n"); +} + +function validateHeightmapFlip() { + local source=heightmap(3,2),flipped=heightmap(1,1); + local value=1.0; + for(local y=0;y<2;++y)for(local x=0;x<3;++x){source.setHeight(x,y,value);value+=1.0;} + requireResult(eve.flipTerrainHeightmap(flipped,source),"heightmap flip"); + if(flipped.getWidth()!=2 || flipped.getHeight()!=3 || flipped.height(1,2)!=6.0) + throw "heightmap transpose mismatch"; + print("TERRAIN_HEIGHTMAP_FLIP_PASS size="+flipped.getWidth()+"x"+flipped.getHeight()+" tail="+flipped.height(1,2)+"\n"); +} + +function validateHeightmapMeasure(source) { + local minimum=requireResult(eve.measureTerrainHeightmap(source,0),"heightmap minimum"); + local maximum=requireResult(eve.measureTerrainHeightmap(source,1),"heightmap maximum"); + local average=requireResult(eve.measureTerrainHeightmap(source,3),"heightmap average"); + local baseLevel=requireResult(eve.measureTerrainHeightmap(source,4),"heightmap base level"); + if(minimum>maximum || averagemaximum)throw "heightmap statistics mismatch"; + print("TERRAIN_HEIGHTMAP_MEASURE_PASS min="+minimum+" max="+maximum+" avg="+average+" base="+baseLevel+"\n"); +} + +function validateTerraceQuantize(source) { + local output=heightmap(source.getWidth(),source.getHeight()); + local starts=heightmap(2,1),curves=heightmap(3,2); + starts.setHeight(0,0,0.0);starts.setHeight(1,0,0.18); + curves.setHeight(0,0,0.0);curves.setHeight(1,0,0.2);curves.setHeight(2,0,1.0); + curves.setHeight(0,1,0.0);curves.setHeight(1,1,0.8);curves.setHeight(2,1,1.0); + requireResult(eve.quantizeTerrainHeightmapTerraces(output,source,starts,curves),"heightmap terrace curves"); + print("TERRAIN_TERRACE_QUANTIZE_PASS center="+output.height(32,32)+"\n"); +} + +function validateSlopeQueries(source) { + local grid=requireResult(eve.measureTerrainHeightmapSlope(source,32.0,32.0,0),"grid slope query"); + local central=requireResult(eve.measureTerrainHeightmapSlope(source,0.5,0.5,1),"central slope query"); + local average=requireResult(eve.measureTerrainHeightmapSlope(source,0.5,0.5,2),"average slope query"); + print("TERRAIN_SLOPE_QUERY_PASS grid="+grid+" central="+central+" average="+average+"\n"); +} + +function validateHeightmapWrites() { + local target=heightmap(3,2),row=heightmap(2,1),column=heightmap(3,1); + row.setHeight(0,0,0.1);row.setHeight(1,0,0.2); + column.setHeight(0,0,0.3);column.setHeight(1,0,0.4);column.setHeight(2,0,0.5); + requireResult(eve.fillTerrainHeightmap(target,2.0),"heightmap fill"); + requireResult(eve.setTerrainHeightmapSafe(target,-1,9,0.25),"heightmap safe set"); + requireResult(eve.setTerrainHeightmapRow(target,1,row),"heightmap row"); + requireResult(eve.setTerrainHeightmapColumn(target,0,column),"heightmap column"); + print("TERRAIN_HEIGHTMAP_WRITE_PASS corner="+target.height(0,1)+" row="+target.height(1,1)+" column="+target.height(2,0)+"\n"); + requireResult(eve.resetTerrainHeightmap(target),"heightmap reset"); + if(target.getWidth()!=0 || target.getHeight()!=0)throw "heightmap reset mismatch"; +} + +function validatePcgRawLoader() { + local loaded=procgen.loadTerrainBytes("ABCD","raw8"); + local map=retain(requireResult(loaded,"Pcg RAW8 bytes")); + if(loaded.format!="raw8" || loaded.width!=2 || loaded.height!=2 || map.height(1,0)!=(67.0/255.0)) + throw "Pcg RAW traversal mismatch"; + print("TERRAIN_PCG_RAW_PASS format="+loaded.format+" size="+loaded.width+"x"+loaded.height+ + " sample="+map.height(1,0)+"\n"); +} + +function validateLegacyHydraulic() { + local heights=heightmap(3,1),sediment=heightmap(3,1),hardness=heightmap(3,1),rain=heightmap(3,1); + heights.setHeight(1,0,1.0);rain.setHeight(1,0,1.0); + local settings=eve.TerrainLegacyHydraulicSettings();settings.sedimentDissolveRate=1.0; + requireResult(heights.applyLegacyHydraulic(sediment,hardness,rain,settings),"legacy hydraulic erosion"); + if(heights.height(1,0)!=0.6 || sediment.height(0,0)!=0.2 || sediment.height(1,0)!=-0.4) + throw "legacy hydraulic result mismatch"; + print("TERRAIN_LEGACY_HYDRAULIC_PASS center="+heights.height(1,0)+" sediment="+sediment.height(1,0)+"\n"); +} + +function validateLegacyThermal() { + local distributed=heightmap(3,3),steepest=heightmap(3,3),hardness=heightmap(1,1); + distributed.setHeight(1,1,1.0);steepest.setHeight(1,1,1.0); + local a=eve.TerrainLegacyDistributedErosionSettings();a.minimumThreshold=0.1; + requireResult(distributed.applyLegacyDistributedErosion(a),"legacy distributed erosion"); + local b=eve.TerrainLegacySteepestErosionSettings(); + requireResult(steepest.applyLegacySteepestErosion(hardness,b),"legacy steepest erosion"); + if(distributed.height(1,1)!=0.5 || distributed.height(0,1)!=0.125 || + steepest.height(1,1)!=0.5 || steepest.height(1,2)!=0.5) throw "legacy thermal mismatch"; + print("TERRAIN_LEGACY_THERMAL_PASS distributed="+distributed.height(0,1)+ + " steepest="+steepest.height(1,2)+"\n"); +} + +function validateHeightmapAccess() { + local map=heightmap(2,2);map.setHeight(1,0,1.0);map.setHeight(0,1,2.0);map.setHeight(1,1,3.0); + local safe=requireResult(eve.sampleTerrainHeightmapSafe(map,-3,8),"safe heightmap sample"); + local normalized=requireResult(eve.sampleTerrainHeightmapNormalized(map,0.25,0.25),"normalized heightmap sample"); + if(safe!=2.0 || normalized!=1.5 || !requireResult(map.hasData(),"heightmap data query") || + !requireResult(map.isPowerOfTwo(),"heightmap power-of-two query")) + throw "heightmap access mismatch"; + print("TERRAIN_HEIGHTMAP_ACCESS_PASS safe="+safe+" normalized="+normalized+" power2=1\n"); +} + +eve_init = function() { + validateTerrainImageMaskAdapter(); + gfx.setBackgroundColor(0.055, 0.075, 0.10, 1.0); + gfx.setDirectionalLight(-0.45, 0.85, 0.35, 1.6, 1.48, 1.25); + local sourceTerrain = heightmap(65,65); + local processed = heightmap(65,65); + local stamp = heightmap(33,33); + local mask = heightmap(65,65); + local sediment = heightmap(65,65); + local curve = heightmap(2,1); + local reverse = heightmap(2,1); + local one = heightmap(1,1); + curve.setHeight(0,0,0.0); curve.setHeight(1,0,1.0); + reverse.setHeight(0,0,1.0); reverse.setHeight(1,0,0.0); + one.setHeight(0,0,1.0); + for (local y=0; y<65; ++y) for (local x=0; x<65; ++x) { + local h = 0.16 + 0.07*sin(x*0.095)*cos(y*0.075) + 0.018*sin((x+y)*0.3); + sourceTerrain.setHeight(x,y,h); + processed.setHeight(x,y,h); + } + local legacyFlow=heightmap(65,65),flowSettings=eve.TerrainWaterFlowMapSettings(); + flowSettings.dropletVolume=0.12;flowSettings.absorptionRate=0.04;flowSettings.smoothIterations=1; + requireResult(eve.generateTerrainWaterFlowMap(legacyFlow,sourceTerrain,flowSettings),"legacy water flow map"); + local flowSum=0.0;for(local fy=0;fy<65;++fy)for(local fx=0;fx<65;++fx)flowSum+=legacyFlow.height(fx,fy); + if(flowSum<=0.0)throw "empty legacy water flow map"; + print("TERRAIN_WATER_FLOW_MAP_PASS sum="+flowSum+"\n"); + validateVelocityFlow(sourceTerrain); + validateDerivedMaps(sourceTerrain); + validateNeighborhoodFilters(sourceTerrain); + validateHeightmapFilters(sourceTerrain); + validateSlopeAndQuantize(sourceTerrain); + validateHeightmapArithmetic(sourceTerrain); + validateHeightmapTransforms(sourceTerrain); + validateHeightmapCopy(sourceTerrain); + validateHeightmapFlip(); + validateHeightmapMeasure(sourceTerrain); + validateTerraceQuantize(sourceTerrain); + validateSlopeQueries(sourceTerrain); + validateHeightmapWrites(); + validatePcgRawLoader(); + validateLegacyHydraulic(); + validateLegacyThermal(); + validateHeightmapAccess(); + validateTerraceRemoval(sourceTerrain); + for (local y=0; y<33; ++y) for (local x=0; x<33; ++x) { + local u=(x-16)/16.0, v=(y-16)/16.0; + local h=exp(-3.5*(u*u+v*v))*(0.8+0.2*cos(u*13.0)*cos(v*10.0)); + stamp.setHeight(x,y,h); + } + local distance = eve.TerrainDistanceMaskSettings(); + requireResult(mask.distanceMask(distance,0,reverse,curve),"distance mask"); + local collisionOutput=heightmap(65,65),collisionStack=eve.TerrainCollisionMaskStack(); + requireResult(collisionStack.addLayer(one,0,true,false),"collision radius layer"); + requireResult(collisionStack.apply(collisionOutput,mask,curve,0),"collision mask stack"); + if(collisionStack.getLayerCount()!=1) throw "collision mask layer mismatch"; + print("TERRAIN_COLLISION_MASK_PASS layers="+collisionStack.getLayerCount()+"\n"); + local polygonOutput=heightmap(65,65),polygonBrush=heightmap(1,1),polygon=eve.TerrainPolygonMask(); + requireResult(polygon.addNode(10.0,10.0,0.5,1.0),"polygon node 0"); + requireResult(polygon.addNode(20.0,10.0,0.5,1.0),"polygon node 1"); + requireResult(polygon.addNode(15.0,20.0,0.5,1.0),"polygon node 2"); + requireResult(polygon.rasterize(polygonOutput,polygonBrush,0.0,0.0,1.0,1.0,1),"closed polygon mask"); + if(polygonOutput.height(15,15)!=1.0) throw "polygon flood fill mismatch"; + print("TERRAIN_POLYGON_MASK_PASS nodes="+polygon.getNodeCount()+"\n"); + local biomeMaskCache=eve.TerrainBakedMaskCache(),cachedBiomeMask=heightmap(17,17); + requireResult(biomeMaskCache.store("terrain-main","biome-guid",polygonOutput),"cache world biome mask"); + requireResult(biomeMaskCache.copyMask("terrain-main","biome-guid",cachedBiomeMask),"copy world biome mask"); + if(requireResult(biomeMaskCache.markDirty("biome-guid"),"dirty world biome mask")!=1) throw "biome dirty mismatch"; + if(biomeMaskCache.copyMask("terrain-main","biome-guid",cachedBiomeMask).ok) throw "dirty biome mask remained readable"; + requireResult(biomeMaskCache.store("terrain-main","biome-guid",polygonOutput),"rebuild world biome mask"); + print("TERRAIN_WORLD_BIOME_MASK_PASS entries="+biomeMaskCache.getEntryCount()+"\n"); + local globalSpawnerMask=heightmap(65,65),globalMaskSettings=eve.TerrainImageMaskSettings(); + requireResult(globalSpawnerMask.applyGlobalSpawnerMask(mask,cachedBiomeMask,curve,globalMaskSettings,0), + "global spawner mask output"); + print("TERRAIN_GLOBAL_SPAWNER_MASK_PASS\n"); + local noiseMask=heightmap(65,65),noiseSettings=eve.TerrainNoiseMaskSettings(); + noiseSettings.octaves=3.5;noiseSettings.warpIterations=1.5;noiseSettings.warpStrength=0.2;noiseSettings.seed=42; + requireResult(noiseMask.generateNoiseMask(mask,curve,noiseSettings,0,0),"terrain noise mask"); + print("TERRAIN_NOISE_MASK_PASS types=5\n"); + local smoothMask=heightmap(65,65); + requireResult(smoothMask.smoothMask(noiseMask,0.0,1.0),"terrain smooth mask"); + print("TERRAIN_SMOOTH_MASK_PASS passes=2\n"); + local settings = eve.TerrainStampSettings(); + settings.setCenter(32.0,32.0); + settings.setSize(54.0,44.0); + settings.setRotation(0.38); + settings.amplitude=0.68; + settings.baseHeight=0.05; + local generation=eve.TerrainGenerationSession(); + requireResult(generation.reset(processed),"generation baseline"); + requireResult(generation.stamp(stamp,settings,0,one,one),"record rotated stamp"); + requireResult(generation.undo(),"generation undo"); + requireResult(generation.redo(),"generation redo"); + requireResult(generation.contrast(mask,2.0,1.5),"contrast"); + local terrace = eve.TerrainTerraceSettings(); + terrace.count=30.0; terrace.bevel=0.6; terrace.strength=0.65; + requireResult(generation.terrace(mask,terrace),"terrace"); + local smooth = eve.TerrainSmoothSettings(); + smooth.radius=0.4; smooth.strength=0.3; + requireResult(generation.smooth(mask,smooth),"smooth"); + local thermal = eve.TerrainThermalSettings(); + thermal.heightScale=28.0; thermal.reposeSlope=0.7; + thermal.dt=0.01; thermal.iterations=8; + requireResult(generation.thermal(thermal),"record thermal erosion"); + requireResult(generation.undo(),"undo thermal erosion"); + requireResult(generation.redo(),"redo thermal erosion"); + if(requireResult(generation.beginReplay(),"generation staged begin")!=1) throw "staged replay not pending"; + if(requireResult(generation.stepReplay(2),"generation staged step")!=1) throw "staged replay ended early"; + if(requireResult(generation.cancelReplay(),"generation staged cancel")!=3) throw "staged replay not cancelled"; + requireResult(generation.beginReplay(),"generation staged restart"); + if(requireResult(generation.stepReplay(100),"generation staged complete")!=2) throw "staged replay incomplete"; + requireResult(generation.copyTerrain(processed),"generation terrain output"); + requireResult(generation.copySediment(sediment),"generation sediment output"); + local generationJson=requireResult(generation.snapshotJson(),"generation snapshot"); + local restoredGeneration=eve.TerrainGenerationSession(); + requireResult(restoredGeneration.restoreJson(generationJson),"generation restore"); + requireResult(restoredGeneration.copyTerrain(processed),"restored generation terrain"); + requireResult(restoredGeneration.copySediment(sediment),"restored generation sediment"); + if(restoredGeneration.getOperationCount()!=generation.getOperationCount()) throw "generation history restore mismatch"; + print("TERRAIN_SESSION_PASS operations="+generation.getOperationCount()+" staged=1 persisted=1\n"); + local tileWest=heightmap(3,3),tileEast=heightmap(3,3),tileStamp=heightmap(1,1),tileOne=heightmap(1,1); + tileStamp.setHeight(0,0,0.75);tileOne.setHeight(0,0,1.0); + local multi=eve.TerrainMultiTileWorkspace(); + requireResult(multi.addTile("west",tileWest,0.0,0.0,2.0,2.0,false),"multi-tile west"); + requireResult(multi.addTile("east",tileEast,2.0,0.0,2.0,2.0,false),"multi-tile east"); + local multiStamp=eve.TerrainStampSettings();multiStamp.setCenter(2.0,1.0);multiStamp.setSize(2.0,2.0); + requireResult(multi.stamp(tileStamp,multiStamp,3,tileOne,tileOne,false),"multi-tile stamp"); + if(multi.getLastAffectedTiles()!=2 || multi.getLastMappingCount()!=2) throw "multi-tile mapping mismatch"; + requireResult(multi.undo(),"multi-tile undo");requireResult(multi.redo(),"multi-tile redo"); + print("TERRAIN_MULTITILE_PASS tiles="+multi.getLastAffectedTiles()+"\n"); + local worldConfig=eve.TerrainWorldCreationSettings();worldConfig.tilesX=2;worldConfig.tilesZ=1; + worldConfig.tileSize=2.0;worldConfig.tileHeight=8.0;worldConfig.heightmapResolution=3; + worldConfig.controlTextureResolution=2;worldConfig.detailResolution=2;worldConfig.treeResolution=2; + worldConfig.objectResolution=2;worldConfig.splatLayers=2;worldConfig.nameSuffix="runtime"; + local world=eve.TerrainWorldWorkspace();requireResult(world.create(worldConfig),"world create"); + local worldBounds=eve.TerrainStampSettings();worldBounds.setCenter(0.0,0.0);worldBounds.setSize(4.0,2.0); + requireResult(world.stamp(tileStamp,worldBounds,tileOne,tileOne),"world stamp"); + if(world.getTileCount()!=2 || world.getOperationCount()!=1 || world.getTileName(0).value!="Terrain_0_0-runtime") throw "world topology mismatch"; + requireResult(world.undo(),"world undo");requireResult(world.redo(),"world redo"); + requireResult(world.flatten(),"world flatten");requireResult(world.undo(),"world flatten undo"); + local clearWorld=eve.TerrainWorldClearSettings(); + requireResult(world.clearSpawns(clearWorld),"world clear spawns");requireResult(world.undo(),"world clear undo"); + local worldFitness=heightmap(4,2);for(local wz=0;wz<2;++wz) for(local wx=0;wx<4;++wx) worldFitness.setHeight(wx,wz,1.0); + local worldDetail=eve.TerrainDetailSettings();worldDetail.minimumFitness=0.0;worldDetail.fadeStart=0.0; + worldDetail.density=2.0;worldDetail.namespaceId=4201; + local worldProbe=eve.TerrainProbePlacementSettings();worldProbe.name="runtime-reflection"; + worldProbe.spacing=1.0;worldProbe.jitterPercent=0.0;worldProbe.minimumFitness=0.0; + worldProbe.namespaceId=4202;worldProbe.maxPoints=100; + local spawnPlan=eve.TerrainSpawnPlan(); + requireResult(spawnPlan.addModifierStamp("runtime-modifier",tileStamp,worldBounds,3,tileOne,tileOne),"spawn plan modifier stamp"); + requireResult(spawnPlan.addSplat("runtime-texture",worldFitness,1,worldBounds),"spawn plan texture"); + requireResult(spawnPlan.addDetail("runtime-detail",worldFitness,worldDetail,worldBounds,0,29),"spawn plan detail"); + requireResult(spawnPlan.addProbes("runtime-probe",worldFitness,worldProbe,worldBounds,0,0),"spawn plan probe"); + local spawnPlanJson=requireResult(spawnPlan.snapshotJson(),"spawn plan snapshot"); + local restoredPlan=eve.TerrainSpawnPlan();requireResult(restoredPlan.restoreJson(spawnPlanJson),"spawn plan restore"); + local biomePreset=eve.TerrainBiomePreset(); + requireResult(biomePreset.addSpawner("runtime-forest",restoredPlan,true,false,true),"biome preset add"); + local biomeJson=requireResult(biomePreset.snapshotJson(),"biome preset snapshot"); + local restoredBiome=eve.TerrainBiomePreset();requireResult(restoredBiome.restoreJson(biomeJson),"biome preset restore"); + if(requireResult(world.beginSpawnBiome(restoredBiome),"world biome staged begin")!=1) throw "world spawn not pending"; + if(requireResult(world.stepSpawn(1),"world biome staged step")!=1) throw "world spawn ended early"; + if(requireResult(world.cancelSpawn(),"world biome staged cancel")!=3) throw "world spawn not cancelled"; + requireResult(world.beginSpawnBiome(restoredBiome),"world biome staged restart"); + if(requireResult(world.stepSpawn(100),"world biome staged complete")!=2) throw "world spawn incomplete"; + local worldProbes=retain(requireResult(procgen.newPointSet(),"world probe output")); + if(requireResult(world.copyProbes(0,worldProbes),"world probe copy")<=0) throw "world probes missing"; + local publishedProbeCount=requireResult(eve.publishTerrainProbes("pcg-world-probes",worldProbes),"probe publish"); + if(eve.getTerrainProbeBatchCount("pcg-world-probes")!=publishedProbeCount || + eve.getTerrainReflectionProbeCount("pcg-world-probes")!=publishedProbeCount || + eve.getTerrainLightProbeCount("pcg-world-probes")!=0) throw "probe provider mismatch"; + requireResult(eve.tickTerrainProbeBatches(1,0,64),"probe capture tick"); + if(requireResult(eve.removeTerrainProbeBatch("pcg-world-probes"),"probe remove")!=publishedProbeCount || + eve.getTerrainProbeBatchCount("pcg-world-probes")!=0) throw "probe provider removal mismatch"; + requireResult(eve.publishTerrainProbes("pcg-world-probes",worldProbes),"probe republish"); + print("TERRAIN_PROBE_PROVIDER_PASS probes="+publishedProbeCount+" captureFaces=1\n"); + print("TERRAIN_SPAWN_PLAN_PASS rules="+restoredPlan.getRuleCount()+" persisted=1 modifier=1 probe=1\n"); + print("TERRAIN_BIOME_PRESET_PASS spawners="+restoredBiome.getSpawnerCount()+" persisted=1 staged=1\n"); + print("TERRAIN_WORLD_PASS tiles="+world.getTileCount()+" operations="+world.getOperationCount()+"\n"); + local splatSource=eve.TerrainSplatmap(),splatOut=eve.TerrainSplatmap(); + requireResult(splatSource.initialize(2,2,3,0),"splat source"); + local splatPaint=heightmap(4,2);for(local sy=0;sy<2;++sy) for(local sx=0;sx<4;++sx) splatPaint.setHeight(sx,sy,sx<2?0.25:0.75); + local splatBounds=eve.TerrainStampSettings();splatBounds.setCenter(2.0,1.0);splatBounds.setSize(4.0,2.0); + local multiSplat=eve.TerrainMultiSplatWorkspace(); + requireResult(multiSplat.addTile("west",splatSource,0.0,0.0,2.0,2.0,false),"multi-splat west"); + requireResult(multiSplat.addTile("east",splatSource,2.0,0.0,2.0,2.0,false),"multi-splat east"); + requireResult(multiSplat.paint(splatPaint,1,splatBounds,false),"multi-splat paint"); + requireResult(multiSplat.copyTile("east",splatOut),"multi-splat copy"); + if(splatOut.sample(1,0,0).value!=0.75 || multiSplat.getLastChangedSamples()!=8) throw "multi-splat mapping mismatch"; + requireResult(multiSplat.undo(),"multi-splat undo");requireResult(multiSplat.redo(),"multi-splat redo"); + print("TERRAIN_MULTISPLAT_PASS tiles="+multiSplat.getTileCount()+"\n"); + local objectWest=retain(requireResult(procgen.newPointSet(),"multi-object west")); + local objectEast=retain(requireResult(procgen.newPointSet(),"multi-object east")); + local objectOut=retain(requireResult(procgen.newPointSet(),"multi-object output")); + local objectHeights=heightmap(2,2);for(local oz=0;oz<2;++oz) for(local ox=0;ox<2;++ox) objectHeights.setHeight(ox,oz,0.2); + local objectFitness=heightmap(4,2);for(local oz=0;oz<2;++oz) for(local ox=0;ox<4;++ox) objectFitness.setHeight(ox,oz,1.0); + local objectBounds=eve.TerrainStampSettings();objectBounds.setCenter(146.0,3.0);objectBounds.setSize(4.0,2.0); + local objectChild=eve.TerrainObjectInstanceSettings();objectChild.asset="pcg:terrain-object/rock"; + local objectRule=eve.TerrainObjectPlacementSettings();objectRule.spacing=1.0;objectRule.spawnDensity=1.0; + objectRule.jitterPercent=0.0;objectRule.minimumFitness=0.0;objectRule.minimumInstanceFitness=0.0; + objectRule.prototype="pcg-rocks";objectRule.namespaceId=94;objectRule.seed=73;objectRule.maxPoints=32; + objectRule.boundsRadius=0.2;requireResult(objectRule.addInstance(objectChild),"multi-object child"); + local multiObject=eve.TerrainMultiObjectWorkspace(); + requireResult(multiObject.addTile("west",objectWest,objectHeights,144.0,2.0,2.0,2.0,2,2,false),"multi-object west"); + requireResult(multiObject.addTile("east",objectEast,objectHeights,146.0,2.0,2.0,2.0,2,2,false),"multi-object east"); + requireResult(multiObject.apply(objectFitness,objectRule,objectBounds,0,false),"multi-object add"); + local objectCount=requireResult(multiObject.copyTile("east",objectOut),"multi-object copy"); + if(objectCount<=0 || multiObject.getLastAffectedTiles()!=2) throw "multi-object transaction mismatch"; + requireResult(multiObject.undo(),"multi-object undo");requireResult(multiObject.redo(),"multi-object redo"); + print("TERRAIN_MULTIOBJECT_PASS points="+objectCount+"\n"); + local detailWest=eve.TerrainDetailLayer(),detailEast=eve.TerrainDetailLayer(),detailOut=eve.TerrainDetailLayer(); + requireResult(detailWest.reset(3,3,1),"multi-detail west");requireResult(detailEast.reset(3,3,1),"multi-detail east"); + requireResult(detailOut.reset(3,3,0),"multi-detail output"); + local detailWindow=heightmap(4,3);for(local dz=0;dz<3;++dz) for(local dx=0;dx<4;++dx) detailWindow.setHeight(dx,dz,1.0); + local detailRule=eve.TerrainDetailSettings();detailRule.minimumFitness=0.0;detailRule.fadeStart=0.0;detailRule.density=4.0; + local multiDetail=eve.TerrainMultiDetailWorkspace(); + requireResult(multiDetail.addTile("west",detailWest,0.0,0.0,2.0,2.0,false),"multi-detail add west"); + requireResult(multiDetail.addTile("east",detailEast,2.0,0.0,2.0,2.0,false),"multi-detail add east"); + requireResult(multiDetail.apply(detailWindow,detailRule,multiStamp,0,37,false),"multi-detail apply"); + requireResult(multiDetail.copyTile("east",detailOut),"multi-detail copy"); + if(multiDetail.getLastAffectedTiles()!=2 || detailOut.sample(0,1).value!=4) throw "multi-detail mapping mismatch"; + requireResult(multiDetail.undo(),"multi-detail undo");requireResult(multiDetail.redo(),"multi-detail redo"); + print("TERRAIN_MULTIDETAIL_PASS tiles="+multiDetail.getTileCount()+"\n"); + for (local y=0; y<65; ++y) for (local x=0; x<65; ++x) { + if (processed.height(x,y)!=sourceTerrain.height(x,y)) ++pcg_validation.changed; + pcg_validation.sediment += sediment.height(x,y); + } + local displaySplat=eve.TerrainSplatmap();requireResult(displaySplat.initialize(32,32,3,0),"display splat"); + local displayPaint=heightmap(32,32);for(local py=0;py<32;++py) for(local px=0;px<32;++px) { + local ridge=0.15+0.75*abs(sin(px*0.19)*cos(py*0.13));displayPaint.setHeight(px,py,ridge); + } + requireResult(displaySplat.paint(displayPaint,1),"display splat vegetation"); + local displayRock=heightmap(32,32);for(local py=0;py<32;++py) for(local px=0;px<32;++px) { + local dx=(px-16)/16.0,dy=(py-16)/16.0;displayRock.setHeight(px,py,0.45*exp(-3.0*(dx*dx+dy*dy))); + } + requireResult(displaySplat.paint(displayRock,2),"display splat rock"); + applyGtsLayerHeightBlend(displaySplat,displayPaint,displayRock); + local terrainTextureMask=heightmap(32,32); + requireResult(displaySplat.copyLayer(1,terrainTextureMask),"terrain texture mask source"); + if(terrainTextureMask.height(0,0)!=displaySplat.sample(1,0,0).value) throw "terrain texture mask mismatch"; + print("TERRAIN_TEXTURE_MASK_PASS layer=1\n"); + local splatImage=retain(eve.Image().newEmptyImageData(32,32,"RGBA8")); + local weatherNormal=retain(eve.Image().newEmptyImageData(32,32,"RGBA8")); + local weatherHeights=heightmap(32,32); + for(local wz=0;wz<32;++wz) for(local wx=0;wx<32;++wx) weatherHeights.setHeight(wx,wz,processed.height(wx*2,wz*2)*28.0); + bakeGtsLayerTextures(gtsHeightSplat,splatImage,weatherNormal,weatherHeights); + print("TERRAIN_SPLAT_ALBEDO_PASS pixels=1024\n"); + gtsGlobalBlend=heightmap(32,32); + requireResult(eve.generateGtsGlobalBlendDistance(gtsGlobalBlend,weatherHeights,137.0,96.0,151.0,0.1,1.0,78.0,0.0,0.0,2.0,2.0),"GTS global blend"); + print("TERRAIN_GTS_GLOBAL_BLEND_PASS camera=137,96,151\n"); + applyGtsColorMap(splatImage); + applyGtsGeological(splatImage,weatherNormal,weatherHeights); + applyGtsDetail(splatImage,weatherNormal); + local snowAlbedo=retain(eve.Image().newEmptyImageData(2,2,"RGBA8")); + snowAlbedo.setPixel(0,0,0.90,0.94,1.0,1.0);snowAlbedo.setPixel(1,0,0.76,0.86,0.96,1.0); + snowAlbedo.setPixel(0,1,0.82,0.90,0.98,1.0);snowAlbedo.setPixel(1,1,0.94,0.97,1.0,1.0); + local snowMask=retain(eve.Image().newEmptyImageData(1,1,"RGBA8"));snowMask.setPixel(0,0,1.0,0.5,0.5,1.0); + local gtsSnow=eve.GtsSnowSurfaceSettings();gtsSnow.enabled=true;gtsSnow.minimumHeight=8.4; + gtsSnow.blendRange=2.24;gtsSnow.slopeBlend=5.0;gtsSnow.scale=11.0;gtsSnow.colorR=0.92;gtsSnow.colorG=0.96; + local gtsRain=eve.GtsRainSurfaceSettings();gtsRain.enabled=true;gtsRain.power=0.32; + gtsRain.minimumHeight=0.0;gtsRain.maximumHeight=6.72;gtsRain.darkness=0.18; + requireResult(eve.bakeGtsWeatherAlbedoDetailed(splatImage,weatherHeights,snowAlbedo,snowMask,gtsSnow,gtsRain,gtsDetailGreyscale,78.0,0.0,2.0,2.0),"GTS weather albedo"); + print("TERRAIN_GTS_WEATHER_PASS snow=1 rain=1 pixels=1024\n"); + applyGtsMacroVariation(splatImage); + local weatherMask=retain(eve.Image().newEmptyImageData(32,32,"RGBA32F")); + for(local pnz=0;pnz<32;++pnz) for(local pnx=0;pnx<32;++pnx) { + weatherMask.setPixel(pnx,pnz,0.0,1.0,0.0,0.35); + } + local snowNormal=retain(eve.Image().newEmptyImageData(1,1,"RGBA32F"));snowNormal.setPixel(0,0,0.5,0.5,1.0,1.0); + local rainData=retain(eve.Image().newEmptyImageData(2,2,"RGBA32F")); + rainData.setPixel(0,0,0.17,0.43,0.71,0.91);rainData.setPixel(1,0,0.83,0.29,0.59,0.11); + rainData.setPixel(0,1,0.37,0.97,0.23,0.67);rainData.setPixel(1,1,0.73,0.07,0.47,0.89); + local weatherDisplacement=gtsLayerDisplacement,weatherTessellation=gtsLayerTessellation; + requireResult(eve.bakeGtsWeatherPbr(weatherNormal,weatherMask,weatherDisplacement,weatherTessellation,weatherHeights,snowNormal,snowMask,rainData,gtsSnow,gtsRain,2.0,78.0,0.0,2.0,2.0),"GTS weather PBR"); + print("TERRAIN_GTS_PBR_PASS outputs=4 time=2\n"); + local weatherHeightImage=retain(eve.Image().newEmptyImageData(32,32,"RGBA8")); + for(local whz=0;whz<32;++whz) for(local whx=0;whx<32;++whx) { + local value=weatherDisplacement.height(whx,whz);if(value<0.0)value=0.0;if(value>1.0)value=1.0; + weatherHeightImage.setPixel(whx,whz,value,value,value,1.0); + } + local weatherNormalTexture=retain(gfx.newTexture(weatherNormal,false,false)); + local weatherHeightTexture=retain(gfx.newTexture(weatherHeightImage,false,false)); + print("TERRAIN_GTS_MATERIAL_PASS packedNormalMask=1 parallax=1\n"); + showTerrain(sourceTerrain,0.0); + showTerrain(processed,78.0,splatImage,weatherNormalTexture,weatherHeightTexture); + local detail=eve.TerrainDetailLayer(); + requireResult(detail.reset(32,32,0),"detail layer"); + local fitness=heightmap(32,32); + for(local z=0;z<32;++z) for(local x=0;x<32;++x) { + local h=processed.height(x*2,z*2); + fitness.setHeight(x,z,h>0.15 && h<0.31 ? 1.0 : 0.0); + } + local detailRule=eve.TerrainDetailSettings();detailRule.density=5.0; + requireResult(detail.apply(fitness,detailRule,0,31),"detail density"); + local points=retain(requireResult(procgen.newPointSet(),"detail points")); + local placement=eve.TerrainDetailPlacementSettings(); + placement.originX=78.0;placement.width=64.0;placement.depth=64.0; + placement.heightScale=28.0;placement.minimumScale=0.8;placement.maximumScale=1.2; + placement.minimumWidth=0.55;placement.maximumWidth=1.4; + placement.minimumHeight=0.8;placement.maximumHeight=1.8; + placement.healthyR=0.72;placement.healthyG=0.95;placement.healthyB=0.48; + placement.dryR=0.62;placement.dryG=0.46;placement.dryB=0.20; + placement.noiseSpread=0.3;placement.noiseSeed=31; + placement.asset="pcg:0ba274ba3ae7d3a4482573ef44ef9a30";placement.namespaceId=31; + local pointCount=requireResult(eve.exportTerrainDetailPoints(points,detail,processed,placement),"detail placement"); + local firstColor=points.getColorR(0),variedColor=false; + for(local i=0;iplacement.healthyR || + gplacement.healthyG || + bplacement.healthyB) throw "detail color range mismatch"; + if(r!=firstColor) variedColor=true; + } + if(!variedColor) throw "detail color noise did not vary"; + print("TERRAIN_DETAIL_COLOR_PASS\n"); + terrainGrass=retain(gfx.newGrassField()); + requireResult(eve.bakeTerrainGrass(terrainGrass,points,"unused:resource",0.9,1.3),"empty resource group"); + if(terrainGrass.getDenseCount()!=0) throw "unexpected resource group"; + local imageApi=eve.Image(); + local grassImage=retain(imageApi.newImageDataFromFile("assets/PW_Grass_Terrain_01_D.png")); + local grassNormal=retain(imageApi.newEmptyImageData(grassImage.getWidth(),grassImage.getHeight(),"RGBA8")); + local grassMask=retain(imageApi.newEmptyImageData(grassImage.getWidth(),grassImage.getHeight(),"RGBA8")); + for(local gy=0;gy0.0001)throw "spawn progress mismatch"; + + local taskQueue=eve.PcgTaskQueue();local taskAdd=taskQueue.add(0.25); + if(!taskAdd.ok)throw "task add failed";local taskId=taskAdd.value; + requireResult(taskQueue.tick(0.25),"task tick"); + if(taskQueue.getReadyTaskId()!=taskId)throw "task queue mismatch"; + requireResult(taskQueue.resolveReady(true),"task resolve"); + print("PCG_RUNTIME_ORCHESTRATION_PASS\n"); + + setupTerrainTreeWind(treePlacement.bendFactor); + buildTerrainTreeMesh(); + for(local i=0;i semantic layers -> autotile -> random selection -> grid-to-points)` +`-> PointGraph(transform)` +`-> BuildLayerStack + IncrementalBuildExecutor(cluster delta)` +`-> MeshGraph(grid tiles + point instancing)` +`-> MeshBuild -> Vulkan renderables`. + +The example uses four independent build layers: + +- autotile-mask floor groups with per-group material tints; +- a raised boundary-wall layer extracted from the roguelike generator's wall-autotile masks; +- a gold navigation layer produced by `grid.path` between deterministic endpoints; +- sparse procedural rock instances generated through PointGraph. + +The example executes the stack through an 8x8-cell incremental cluster cache. +Its scene cache consumes each upsert/removal delta to replace or hide only the +affected floor and prop renderables, then repeats the same update to prove the +stable delta is empty. Each live cluster also owns a static triangle-mesh +collider derived from the exact same floor artifact. Collider replacement is +construct-before-destroy, and removal destroys the matching body. When the +`physics` module is trimmed, this optional consumer is visibly reported as +`physics=disabled` and rendering continues. The example does not execute a +second full-stack preview build. + +- `R`: regenerate with the next deterministic seed. +- `Space`: pause/resume the camera orbit. + +Run from the repository root: + +```powershell +make run/win32-debug GAME=examples/tileworld-graph-dungeon +``` diff --git a/examples/tileworld-graph-dungeon/config.nut b/examples/tileworld-graph-dungeon/config.nut new file mode 100644 index 00000000..69b51bdd --- /dev/null +++ b/examples/tileworld-graph-dungeon/config.nut @@ -0,0 +1,7 @@ +config <- { + width = 1100, + height = 720, + title = "TileWorld Graph Dungeon", + debug = false, + hotReload = true +}; diff --git a/examples/tileworld-graph-dungeon/main.nut b/examples/tileworld-graph-dungeon/main.nut new file mode 100644 index 00000000..0c301770 --- /dev/null +++ b/examples/tileworld-graph-dungeon/main.nut @@ -0,0 +1,354 @@ +// TileWorldCreator-style runtime example implemented with EVEngine-native graphs. +// R regenerates with the next deterministic seed. Space toggles the camera orbit. + +persist twSeed = 435 +persist twCamera = null +persist twIncrementalBuild = null +persist twClusterParts = {} +persist twPhysicsWorld = null +persist twWalls = null +persist twPath = null +persist twYaw = -0.72 +persist twRotate = true +persist twPreviousKeys = {} + +const TW_WIDTH = 25; +const TW_HEIGHT = 19; +const TW_CELL = 2.0; + +function twRequire(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function twPressed(key) { + local down = keyboard.isDown(key); + local previous = key in twPreviousKeys ? twPreviousKeys[key] : false; + twPreviousKeys[key] <- down; + return down && !previous; +} + +function twBuildRock() { + local params = twRequire(procgen.newParams(), "rock parameters"); + params.setSeed(twSeed + 991); + params.setInt("subdivisions", 0); + params.setString("baseShape", "block"); + params.setFloat("radius", 0.32); + params.setFloat("variation", 0.18); + params.setFloat("flattening", 0.25); + params.setFloat("angularity", 0.65); + return twRequire(procgen.buildMesh("mesh.rock", params), "rock mesh"); +} + +function twBuildClusterCollider(meshBuild) { + if (twPhysicsWorld == null) return null; + local vertices = []; + local indices = []; + for (local i = 0; i < meshBuild.getVertexCount(); ++i) { + vertices.append(meshBuild.getPositionX(i)); + vertices.append(meshBuild.getPositionY(i)); + vertices.append(meshBuild.getPositionZ(i)); + } + for (local i = 0; i < meshBuild.getIndexCount(); ++i) + indices.append(meshBuild.getIndex(i)); + if (vertices.len() < 9 || indices.len() < 3) return null; + + // Construct-before-destroy keeps the previous collision authoritative if + // validation or Box3D mesh creation rejects the replacement. + local body = twPhysicsWorld.newBody("static", 0.0, 0.0, 0.0); + try { + body.newTriangleMeshShape(vertices, indices); + } catch (error) { + twPhysicsWorld.destroyBody(body); + throw error; + } + return body; +} + +function twFirstOccupied(grid) { + for (local y = 0; y < grid.getHeight(); ++y) + for (local x = 0; x < grid.getWidth(); ++x) + if (grid.getCell(x, y) != 0) return [x, y]; + return null; +} + +function twLastOccupied(grid) { + for (local y = grid.getHeight() - 1; y >= 0; --y) + for (local x = grid.getWidth() - 1; x >= 0; --x) + if (grid.getCell(x, y) != 0) return [x, y]; + return null; +} + +function twBuild() { + if (twPhysicsWorld == null && has_module("physics")) + twPhysicsWorld = physics.newWorld3D(0.0, 0.0, 0.0, false); + + // PointGraph is nested in GridGraph: selected grid cells become elevated points. + local pointGraph = twRequire(procgen.newPointGraph(), "point graph"); + if (!pointGraph.addNode("input", "input")) throw "PointGraph input node failed"; + if (!pointGraph.addNode("raise", "transform")) throw "PointGraph transform node failed"; + if (!pointGraph.connect("input", "raise", 0)) throw "PointGraph connection failed"; + if (!pointGraph.setNodeFloat("raise", "y", 0.62)) throw "PointGraph parameter failed"; + + local gridGraph = twRequire(procgen.newGridGraph(), "grid graph"); + // Reuse EVEngine's mature semantic dungeon generator as a graph source. + twRequire(gridGraph.addNode("rooms", "generate.registry"), "registry generator node"); + twRequire(gridGraph.setNodeString("rooms", "algorithm", "level.roguelike"), "generator algorithm"); + twRequire(gridGraph.setNodeInt("rooms", "width", TW_WIDTH), "dungeon width"); + twRequire(gridGraph.setNodeInt("rooms", "height", TW_HEIGHT), "dungeon height"); + twRequire(gridGraph.setNodeInt("rooms", "seed", twSeed), "dungeon seed"); + twRequire(gridGraph.setNodeInt("rooms", "roomCount", 8), "room budget"); + twRequire(gridGraph.setNodeInt("rooms", "roomMin", 4), "minimum room size"); + twRequire(gridGraph.setNodeInt("rooms", "roomMax", 7), "maximum room size"); + twRequire(gridGraph.setNodeString("rooms", "layoutStyle", "clustered"), "room layout"); + twRequire(gridGraph.setNodeString("rooms", "connectionStyle", "nearest"), "room connections"); + twRequire(gridGraph.setNodeString("rooms", "corridorStyle", "l"), "corridor style"); + twRequire(gridGraph.setNodeString("rooms", "floorPattern", "brick"), "floor pattern"); + twRequire(gridGraph.setNodeFloat("rooms", "propDensity", 0.14), "room prop density"); + twRequire(gridGraph.setNodeFloat("rooms", "corridorLightDensity", 0.04), "corridor lights"); + + twRequire(gridGraph.addNode("floor", "select.semantic"), "floor semantic selector"); + twRequire(gridGraph.addNode("corridors", "select.semantic"), "corridor semantic selector"); + twRequire(gridGraph.addNode("walls", "select.semantic"), "wall semantic selector"); + twRequire(gridGraph.setNodeInt("floor", "semantic", 2), "floor semantic"); + twRequire(gridGraph.setNodeInt("corridors", "semantic", 3), "corridor semantic"); + twRequire(gridGraph.setNodeInt("walls", "semantic", 1), "wall semantic"); + twRequire(gridGraph.connect("rooms", "floor", 0), "floor extraction"); + twRequire(gridGraph.connect("rooms", "corridors", 0), "corridor extraction"); + twRequire(gridGraph.connect("rooms", "walls", 0), "wall extraction"); + twRequire(gridGraph.addNode("wall_edges", "select.detail_range"), "wall edge selector"); + twRequire(gridGraph.setNodeInt("wall_edges", "min", 1), "wall edge minimum mask"); + twRequire(gridGraph.setNodeInt("wall_edges", "max", 255), "wall edge maximum mask"); + twRequire(gridGraph.connect("walls", "wall_edges", 0), "wall edge connection"); + twRequire(gridGraph.addNode("walkable", "grid.union"), "walkable union node"); + twRequire(gridGraph.connect("floor", "walkable", 0), "floor union connection"); + twRequire(gridGraph.connect("corridors", "walkable", 1), "corridor union connection"); + twRequire(gridGraph.addNode("autotile", "grid.autotile"), "autotile node"); + twRequire(gridGraph.connect("walkable", "autotile", 0), "autotile connection"); + + twRequire(gridGraph.addNode("scatter", "select.random"), "scatter node"); + twRequire(gridGraph.setNodeFloat("scatter", "weight", 0.055), "scatter weight"); + twRequire(gridGraph.setNodeInt("scatter", "seed", twSeed + 17), "scatter seed"); + twRequire(gridGraph.connect("autotile", "scatter", 0), "scatter connection"); + twRequire(gridGraph.addNode("points", "convert.grid_to_points"), "point conversion node"); + twRequire(gridGraph.setNodeFloat("points", "cellSize", TW_CELL), "point cell size"); + twRequire(gridGraph.connect("scatter", "points", 0), "point conversion connection"); + twRequire(gridGraph.addNode("point_pass", "point.subgraph"), "PointGraph bridge node"); + twRequire(gridGraph.connect("points", "point_pass", 0), "PointGraph bridge connection"); + twRequire(gridGraph.setNodePointSubgraph("point_pass", pointGraph, "input", "raise"), + "PointGraph bridge binding"); + + local level = twRequire(gridGraph.execute("rooms"), "semantic dungeon execution"); + local grid = twRequire(gridGraph.execute("autotile"), "grid execution"); + local border = twRequire(gridGraph.execute("wall_edges"), "wall execution"); + local points = twRequire(gridGraph.execute("point_pass"), "point execution"); + + // Deterministic Objects Build Layer: weighted assets, random transforms and + // child placements remain ordinary PointSet data for downstream graphs. + local objectLayer = eve.ProcgenObjectBuildLayer(); + twRequire(objectLayer.addAsset("dungeon/rock_a", 3.0), "rock A asset"); + twRequire(objectLayer.addAsset("dungeon/rock_b", 1.0), "rock B asset"); + objectLayer.setSeed(twSeed); + twRequire(objectLayer.setPositionRadius(0.22), "object scatter radius"); + twRequire(objectLayer.setRandomRotation(0.0, 0.0, -180.0, 180.0, 0.0, 0.0), + "object rotation range"); + twRequire(objectLayer.setRandomScale(0.72, 1.18, 0.72, 1.18, 0.72, 1.18, true), + "object scale range"); + twRequire(objectLayer.addChild("dungeon/rubble", 1, 0.48, 0.25, 0.5, -180.0, 180.0), + "rubble child rule"); + local buildStack = eve.ProcgenBuildLayerStack(); + twRequire(buildStack.addTileLayer("floor", true, TW_CELL, 0.34, "dungeon"), "floor build layer"); + twRequire(buildStack.addObjectLayer("props", true, objectLayer), "object build layer"); + local persistedStack = buildStack.serializeDefinition(); + local restoredStack = eve.ProcgenBuildLayerStack(); + twRequire(restoredStack.deserializeDefinition(persistedStack), "build layer restore"); + if (twIncrementalBuild == null) twIncrementalBuild = eve.ProcgenIncrementalBuildExecutor(); + local clusterDelta = twRequire(twIncrementalBuild.update(restoredStack, grid, points, 8, TW_CELL), + "incremental cluster build"); + local stableDelta = twRequire(twIncrementalBuild.update(restoredStack, grid, points, 8, TW_CELL), + "stable incremental cluster build"); + + // Scene-side cluster cache consumes only upserts/removals from the executor. + // No full BuildLayerStack execution is needed for the visible floor/props. + local rockBuild = twBuildRock(); + for (local i = 0; i < clusterDelta.getCount(); ++i) { + local clusterX = clusterDelta.getClusterX(i); + local clusterZ = clusterDelta.getClusterZ(i); + local key = clusterX + ":" + clusterZ; + if (clusterDelta.isRemoved(i)) { + if (key in twClusterParts) { + twClusterParts[key].floor.setVisible(false); + twClusterParts[key].props.setVisible(false); + if (twPhysicsWorld != null && "collider" in twClusterParts[key] && + twClusterParts[key].collider != null) + twPhysicsWorld.destroyBody(twClusterParts[key].collider); + delete twClusterParts[key]; + } + continue; + } + local artifacts = twRequire(clusterDelta.getArtifacts(i), "cluster artifacts"); + local floorBuild = twRequire(artifacts.getMesh(0), "cluster floor artifact"); + local objectPoints = twRequire(artifacts.getPoints(1), "cluster object artifact"); + local floorMesh = twRequire(procgen.uploadMesh(floorBuild, gfx), "cluster floor upload"); + if (!(key in twClusterParts)) + twClusterParts[key] <- { + floor = eve.Renderable3D(), props = eve.Renderable3D(), collider = null + }; + local part = twClusterParts[key]; + if (!("collider" in part)) part.collider <- null; + local replacementCollider = twBuildClusterCollider(floorBuild); + if (twPhysicsWorld != null && part.collider != null) + twPhysicsWorld.destroyBody(part.collider); + part.collider = replacementCollider; + part.floor.setMesh(floorMesh); + part.floor.setVisible(true); + part.floor.setTint(0.31 + 0.025 * ((clusterX + clusterZ) & 1), 0.43, 0.53, 1.0); + part.floor.setRoughness(0.94); + part.floor.setMetallic(0.02); + part.floor.setCastShadow(true); + part.floor.setReceiveShadow(true); + if (objectPoints.getCount() == 0) { + part.props.setVisible(false); + } else { + local propGraph = twRequire(procgen.newMeshGraph(), "cluster prop mesh graph"); + twRequire(propGraph.addNode("rock", "mesh.input"), "cluster rock input"); + twRequire(propGraph.addNode("points", "point.input"), "cluster point input"); + twRequire(propGraph.addNode("instances", "mesh.instance_points"), "cluster instance node"); + twRequire(propGraph.setNodeMesh("rock", rockBuild), "cluster rock binding"); + twRequire(propGraph.setNodePoints("points", objectPoints), "cluster point binding"); + twRequire(propGraph.setNodeFloat("instances", "scale", 1.0), "cluster instance scale"); + twRequire(propGraph.connect("rock", "instances", 0), "cluster rock connection"); + twRequire(propGraph.connect("points", "instances", 1), "cluster point connection"); + local propBuild = twRequire(propGraph.execute("instances"), "cluster instance execution"); + local propMesh = twRequire(procgen.uploadMesh(propBuild, gfx), "cluster prop upload"); + part.props.setMesh(propMesh); + part.props.setVisible(true); + part.props.setTint(0.80, 0.48, 0.19, 1.0); + part.props.setRoughness(0.78); + part.props.setMetallic(0.08); + part.props.setCastShadow(true); + part.props.setReceiveShadow(true); + } + } + + // Feed explicit owned Grid2D endpoint masks into GridGraph. This exercises the + // public newGrid -> GridGraph mixed-composition path, not an internal shortcut. + local startCell = null; + local targetCell = null; + for (local i = 0; i < level.getObjectCount(); ++i) { + local role = level.getObjectType(i); + if (role == "spawn") startCell = [level.getObjectX(i).tointeger(), level.getObjectY(i).tointeger()]; + if (role == "stairs") targetCell = [level.getObjectX(i).tointeger(), level.getObjectY(i).tointeger()]; + } + if (startCell == null) startCell = twFirstOccupied(grid); + if (targetCell == null) targetCell = twLastOccupied(grid); + if (startCell == null || targetCell == null || + (startCell[0] == targetCell[0] && startCell[1] == targetCell[1])) + throw "generated layout has no distinct navigable endpoint pair"; + local startSelection = twRequire(procgen.newGrid(TW_WIDTH, TW_HEIGHT), "route start grid"); + local targetSelection = twRequire(procgen.newGrid(TW_WIDTH, TW_HEIGHT), "route target grid"); + twRequire(startSelection.fill(0), "route start clear"); + twRequire(targetSelection.fill(0), "route target clear"); + twRequire(startSelection.setCell(startCell[0], startCell[1], 1), "route start cell"); + twRequire(targetSelection.setCell(targetCell[0], targetCell[1], 1), "route target cell"); + twRequire(gridGraph.addNode("route_start", "grid.input"), "route start input"); + twRequire(gridGraph.addNode("route_target", "grid.input"), "route target input"); + twRequire(gridGraph.setNodeGrid("route_start", startSelection), "route start binding"); + twRequire(gridGraph.setNodeGrid("route_target", targetSelection), "route target binding"); + twRequire(gridGraph.addNode("route", "grid.path"), "path node"); + twRequire(gridGraph.connect("autotile", "route", 0), "navigation connection"); + twRequire(gridGraph.connect("route_start", "route", 1), "start connection"); + twRequire(gridGraph.connect("route_target", "route", 2), "target connection"); + local route = twRequire(gridGraph.execute("route"), "path execution"); + local routeCells = 0; + for (local y = 0; y < TW_HEIGHT; ++y) + for (local x = 0; x < TW_WIDTH; ++x) + if (route.getCell(x, y) != 0) routeCells += 1; + if (routeCells < 2) throw "path output is unexpectedly short"; + + // A second build layer raises only the selected boundary cells into walls. + local wallGraph = twRequire(procgen.newMeshGraph(), "wall mesh graph"); + twRequire(wallGraph.addNode("grid", "grid.input"), "wall grid input"); + twRequire(wallGraph.addNode("walls", "mesh.grid_tiles"), "wall mesh node"); + twRequire(wallGraph.setNodeGrid("grid", border), "wall grid binding"); + twRequire(wallGraph.setNodeFloat("walls", "cellSize", TW_CELL), "wall cell size"); + twRequire(wallGraph.setNodeFloat("walls", "height", 1.65), "wall height"); + twRequire(wallGraph.setNodeString("walls", "group", "walls"), "wall group"); + twRequire(wallGraph.connect("grid", "walls", 0), "wall connection"); + local wallBuild = twRequire(wallGraph.execute("walls"), "wall mesh execution"); + local wallMesh = twRequire(procgen.uploadMesh(wallBuild, gfx), "wall mesh upload"); + + // Navigation is another independent build layer laid just above the floor. + local pathGraph = twRequire(procgen.newMeshGraph(), "path mesh graph"); + twRequire(pathGraph.addNode("grid", "grid.input"), "path grid input"); + twRequire(pathGraph.addNode("path", "mesh.grid_tiles"), "path mesh node"); + twRequire(pathGraph.setNodeGrid("grid", route), "path grid binding"); + twRequire(pathGraph.setNodeFloat("path", "cellSize", TW_CELL), "path cell size"); + twRequire(pathGraph.setNodeFloat("path", "height", 0.43), "path height"); + twRequire(pathGraph.setNodeString("path", "group", "navigation"), "path group"); + twRequire(pathGraph.connect("grid", "path", 0), "path mesh connection"); + local pathBuild = twRequire(pathGraph.execute("path"), "path mesh execution"); + local pathMesh = twRequire(procgen.uploadMesh(pathBuild, gfx), "path mesh upload"); + + if (twWalls == null) twWalls = eve.Renderable3D(); + twWalls.setMesh(wallMesh); + twWalls.setTint(0.18, 0.24, 0.31, 1.0); + twWalls.setRoughness(0.88); + twWalls.setMetallic(0.06); + twWalls.setCastShadow(true); + twWalls.setReceiveShadow(true); + + if (twPath == null) twPath = eve.Renderable3D(); + twPath.setMesh(pathMesh); + twPath.setTint(0.92, 0.56, 0.12, 1.0); + twPath.setRoughness(0.72); + twPath.setMetallic(0.04); + twPath.setCastShadow(false); + twPath.setReceiveShadow(true); + + print("tileworld-graph-dungeon: seed=" + twSeed + + " cells=" + grid.getWidth() + "x" + grid.getHeight() + + " dungeonObjects=" + level.getObjectCount() + + " liveClusters=" + twClusterParts.len() + + " wallTriangles=" + (wallBuild.getIndexCount() / 3) + + " routeCells=" + routeCells + + " props=" + points.getCount() + + " colliderBodies=" + (twPhysicsWorld == null ? 0 : twPhysicsWorld.getBodyCount()) + + " physics=" + (twPhysicsWorld == null ? "disabled" : "clustered") + + " dirtyClusters=" + clusterDelta.getCount() + + " stableClusters=" + stableDelta.getCount() + "\n"); +} + +if (twCamera == null) { + twCamera = eve.Camera3D(); + twCamera.setActive(true); +} +local twCenterX = TW_WIDTH * TW_CELL * 0.5; +local twCenterZ = TW_HEIGHT * TW_CELL * 0.5; +twCamera.setEye(twCenterX + cos(twYaw) * 76.0, 58.0, + twCenterZ + sin(twYaw) * 76.0); +twCamera.setTarget(twCenterX, 0.0, twCenterZ); +twCamera.setUp(0.0, 1.0, 0.0); +twCamera.setFov(42.0); +twCamera.setClipPlanes(0.1, 300.0); +twCamera.setAmbient(0.31, 0.34, 0.39); +gfx.setDirectionalLight(-0.55, -1.0, -0.35, 1.65, 1.48, 1.24); +gfx.setBackgroundColor(0.035, 0.055, 0.085, 1.0); + +if (twClusterParts.len() == 0 || twWalls == null || twPath == null) twBuild(); + +eve_update = function(dt) { + if (twPressed("r") || twPressed("R")) { + twSeed = (twSeed + 1).tointeger(); + twBuild(); + } + if (twPressed("space")) twRotate = !twRotate; + if (twRotate) twYaw = (twYaw + dt * 0.10).tofloat(); + twCamera.setEye(twCenterX + cos(twYaw) * 76.0, 58.0, + twCenterZ + sin(twYaw) * 76.0); +}; + +eve_render = function() { + gfx.clear(); + gfx.render3D(); +}; diff --git a/examples/uv-texture-paint/README.md b/examples/uv-texture-paint/README.md new file mode 100644 index 00000000..97033971 --- /dev/null +++ b/examples/uv-texture-paint/README.md @@ -0,0 +1,43 @@ +# UV 贴图绘制:点击网格把 Box3D 三角形命中映射回模型 UV 并绘制贴图 + +示例加载 `paintable.obj` 中的平面四边形,并把它同时注册为可渲染模型和 Box3D 静态三角网格碰撞体。 +鼠标左键点击时,示例用相机屏幕射线在物理世界中做三角形拾取,把命中的世界空间点通过 +`model.mapSurfacePointToUv` 映射回模型 UV,再在 512×256 的 RGBA8 贴图上绘制圆形笔刷并上传给 GPU。 +渲染体与碰撞体都是单位变换,所以世界空间命中点即模型本地坐标。 + +## 运行 + +```sh +make run/-debug GAME=examples/uv-texture-paint +``` + +```sh +cd examples/uv-texture-paint && ../../build/linux-debug/src/engine/eve run +``` + +Windows 下可执行文件路径为 `build/win32-debug/src/engine/eve.exe`。 + +## 演示内容 + +- 相机屏幕射线:`camera.screenToRay(mouse.getX(), mouse.getY(), gfx.getWidth().tofloat(), gfx.getHeight().tofloat())`,再用 `getScreenRayOriginX/Y/Z` 与 `getScreenRayDirX/Y/Z` 取出射线,向方向延长 100.0 作为终点。 +- 物理三角形拾取:`physics.newWorld3D(0.0, 0.0, 0.0, false)` 建世界,`world.newBody("static", ...)` 加静态刚体,`body.newTriangleMeshShape(vertices, indices)` 的顶点与索引直接由 `model.getVertexPosition(0, vertex, component)` 和 `model.getFaceVertexIndex(0, triangle, corner)` 展开;`world.rayCast(...)` 后用 `world.hasRayHit()`、`world.getRayHitTriangleIndex()`、`world.getRayHitX/Y/Z` 读取命中结果。 +- UV 回映射:`model.mapSurfacePointToUv(0, world.getRayHitTriangleIndex(), hitX, hitY, hitZ, 0)` 返回 `SurfaceUv`,由 `uv.getTriangleIndex()`、`uv.getU()`、`uv.getV()` 给出三角形与 UV 坐标。 +- 贴图绘制与上传:`eve.Image().newEmptyImageData(512, 256, "RGBA8")` 建图,逐像素 `pixels.setPixel(...)` 写入水平渐变(r 从 0.12 递增到约 0.30,g 0.28,b 0.55),`gfx.newTexture(pixels, false, false)` 建纹理并由 `renderable.setTexture(texture)` 绑定;命中后 `pixels.paintCircleUv(u, v, 14.0, 1.0, 0.12, 0.04, 1.0, false, false)` 画笔刷,再 `gfx.updateTextureFromImageData(texture, pixels)` 把改动刷到显存。 +- 模型来源:`model3d.newModelDataFromFile("paintable.obj")` + `model3d.createRenderable(gfx, model, 0)`;`eve_render` 中 `gfx.clear()` 后 `gfx.render3D()`,背景色为 `gfx.setBackgroundColor(0.04, 0.06, 0.09, 1.0)`。 + +## 操作 + +- 鼠标左键(`mouse.isDown(1)`):按下瞬间(上一帧未按下)触发一次 `paintHit()`,在命中 UV 处画半径 14 像素、颜色 (1.0, 0.12, 0.04)、不透明的圆;按住不放不会连续绘制,需要松开后再次点击。 +- 鼠标位置(`mouse.getX()` / `mouse.getY()`)决定屏幕射线,从而决定落笔位置。 +- 代码中没有键盘绑定,唯一的输入检测就是上述左键。 + +## 输出 + +示例不写任何磁盘文件,绘制只发生在内存中的 512×256 RGBA8 `ImageData` 与 `Texture` 上,退出后不保留; +每次绘制会向控制台打印 `paint: triangle= uv=(, )`。 + +## 相关文件 + +- `main.nut` — 全部示例逻辑:相机、模型加载、物理世界与三角网格、射线拾取、UV 映射、贴图绘制与上传。 +- `config.nut` — 窗口标题 `UV Texture Paint`,窗口尺寸 960×640。 +- `paintable.obj` — z=0 平面上的四边形(x∈[-2, 2]、y∈[-1, 1],法线 +Z):4 个顶点 `v`、4 个 UV `vt`、1 个法线 `vn`、2 个三角形 `f`。 diff --git a/examples/uv-texture-paint/config.nut b/examples/uv-texture-paint/config.nut index c2c03033..a82b6fb7 100644 --- a/examples/uv-texture-paint/config.nut +++ b/examples/uv-texture-paint/config.nut @@ -1,3 +1,7 @@ -window_title <- "UV Texture Paint"; -window_width <- 960; -window_height <- 640; +config <- { + width = 960 + height = 640 + title = "UV Texture Paint" + debug = false + hotReload = true +}; diff --git a/examples/virtual-texture-blending/README.md b/examples/virtual-texture-blending/README.md new file mode 100644 index 00000000..356097a6 --- /dev/null +++ b/examples/virtual-texture-blending/README.md @@ -0,0 +1,46 @@ +# Virtual Texture Blending — 虚拟纹理材质混合 + +本例演示 `Material.setVirtualTexture()`:把两个 64×64 的常驻虚拟页和一个 fallback 槽拼进同一张 +3 槽物理图集,用页表把虚拟页映射到物理槽位。每个页槽四周留 2 像素 gutter 重复相邻边缘颜色, +让页边界与页切换在 `textureGrad` 采样下不产生接缝。 + +## 运行 + +```bash +make run/-debug GAME=examples/virtual-texture-blending +``` + +也可以直接运行引擎(Windows 下把路径换成 `build/win32-debug/src/engine/eve.exe`): + +```bash +cd examples/virtual-texture-blending && ../../build/linux-debug/src/engine/eve run +``` + +## 演示内容 + +- 图集由 `eve.Image().newEmptyImageData(w, h, "RGBA8")` 程序化生成:`tile = 64`、`border = 2`, + 每槽 `slotSize = 68`,横向 3 槽共 204×68。槽 0 是 fallback 槽(整面替身、无页边界), + 槽 1、槽 2 是两个物理页,各存整条岩石→苔藓渐变的一半。 +- 页表是 2×1 RGBA8:红/绿存物理槽中心(0.5 → 槽 1,0.833333 → 槽 2),蓝 > 0.5 表示该页常驻。 + `virtual_texture.glsl` 正是按 `entry.b` 分流:命中取页内 uv,未命中回落到整面 uv 并缩放梯度。 +- 本例只有 2 个虚拟页且都常驻,所以页表两项的蓝通道都是 1,运行时全部走页路径。 +- `gfx.newTexture(image, false, false)` 上传 albedo 图集、平面法线图集和页表,然后调用 + `vtMaterial.setVirtualTexture(albedo, normal, pageTable, 2, 1, 3, 1, border / slotSize)`: + 2×1 个虚拟页、3×1 个物理槽、border fraction = 2/68。 +- 材质参数为 `setRoughness(0.78)` 与 `setMetallic(0.0)`。 +- 地面是 `eve.Renderable3D()` 加 `gfx.newMeshCube(1.0)`,缩放到 (11, 0.35, 7)。 +- 相机 `eve.Camera3D()`:eye (0, 7, 9)、target 为原点、fov 52°,环境光 (0.28, 0.30, 0.32), + 方向光来自 `gfx.setDirectionalLight(-0.35, -1.0, -0.25, 1.2, 1.15, 1.05)`。 +- `gfx.getRenderControl()` 关闭 `shadow` / `gbuffer` / `msaa` / `atmosphere` 后 `compile()`。 +- `eve_render` 在窗口底部用 `gfx.drawSolidRect` 加 `gfx.drawTexturedRect` 内嵌显示整张物理图集, + 可以直接看到 fallback 槽、两个页槽以及页槽边缘的 2 像素 gutter。 + +## 操作 + +- 没有键盘或鼠标交互:相机位姿在 `eve_init` 里固定,`main.nut` 不读取任何输入。 +- `config.nut` 中 `hotReload = true`,保存 `main.nut` 后脚本会自动重载并重建图集与材质。 + +## 相关文件 + +- `main.nut` — 生成图集与页表、设置材质和相机、绘制底部图集内嵌视图。 +- `config.nut` — 1280×720 窗口与标题,以及 `debug`、`hotReload` 开关。 diff --git a/examples/voxel/README.md b/examples/voxel/README.md new file mode 100644 index 00000000..6ee4d343 --- /dev/null +++ b/examples/voxel/README.md @@ -0,0 +1,51 @@ +# Voxel — 流式体素地形与方块建造示例 + +自包含的最小体素示例:脚本侧注册方块类型并程序化生成 4×4 图集,用内置噪声地形 +(`setTerrain` + `streamAround`)自动流式补建玩家周围的 chunk,再通过 DDA 射线 +拾取实现按名字放置与破坏方块。与 `examples/voxel-terrain`(结合 `procgen` 的 +`TerrainSampler`、WASD 飞行与编辑器笔刷)相比,本示例只做最小闭环:脚本程序化 +图集 + 内置噪声地形 + 自动环绕相机。 + +## 运行 + +```sh +make run/-debug GAME=examples/voxel +``` + +也可以直接进入示例目录运行引擎(Linux): + +```sh +cd examples/voxel && ../../build/linux-debug/src/engine/eve run +``` + +Windows 下可执行文件为 `build/win32-debug/src/engine/eve.exe`。 + +## 演示内容 + +- 方块类型注册表:`voxel.newCubeTypes()` 加 `loadFromJson`,注册 `grass`、`stone`、 + `wood` 三种方块,用 `faceTex` 指定每个面的图集编号。 +- 程序化图集:`gfx.newCanvas` 绘制 4×4 个 32 像素图块(1 草顶、2 泥土、3 石头、 + 4 木头),转成纹理后交给 `world.drawVisible(gfx, atlas, TILES_PER_ROW)`。 +- 地形:`world.setTerrain(20260822, 1, 2, 3, 8.0, 14.0, 1.0 / 32.0)` 配置种子、 + 草/土/石纹理 id、基准高度、幅度与缩放。 +- 流式加载:`world.streamAround(0, 0, 0, 2)` 以球形半径 2 个 chunk 自动补建。 +- 网格更新:`world.remeshDirty()` 重建脏 chunk(含边界接缝)。 +- 剔除与绘制:`world.selectVisible(...)` 传入 16 个 float 的 view-proj 做视锥剔除, + 随后 `gfx.setMesh3DViewProj` / `gfx.setMesh3DView` / `gfx.setMesh3DCameraPos` + 与 `gfx.begin3DFrame()`。 +- 射线拾取:`cam.screenToRay` 生成屏幕射线,`world.raycast(...)` 命中后用 + `getRaycastHitX/Y/Z` 与 `getRaycastPrevX/Y/Z` 取命中块与前一格空气块。 +- 相机:单个 `eve.Camera3D`(`setActive(false)`,只用来算矩阵和射线)。 + +## 操作 + +- 鼠标左键点击:在命中面前一格的空气位置放置木头块(`setVoxelByName(..., "wood")`)。 +- 鼠标右键点击:破坏命中方块(`setVoxel(hx, hy, hz, 0)`)。 +- `R`:立即重建所有脏 chunk(`keyboard.isDown("r")` → `remeshDirty()`)。 +- 相机自动环绕:代码用 `elapsed` 的 sin/cos 驱动 `setEye`,没有键盘移动键。 + +## 相关文件 + +- `main.nut`:全部示例逻辑——图集、世界与地形、轨道相机、拾取与放置/破坏。 +- `config.nut`:窗口 900×600、标题 `voxel`、`hotReload = true`。 +- `README.md`:本说明。 diff --git a/platform/android/apk/app/build.gradle.kts b/platform/android/apk/app/build.gradle.kts index cba5443c..5c13f976 100644 --- a/platform/android/apk/app/build.gradle.kts +++ b/platform/android/apk/app/build.gradle.kts @@ -2,6 +2,8 @@ plugins { id("com.android.application") } +val evengineAbi = providers.gradleProperty("evengineAbi").getOrElse("arm64-v8a") + android { namespace = "com.evengine.example" compileSdk = 34 @@ -12,11 +14,11 @@ android { targetSdk = 34 // Derived from EVENGINE_*_VERSION by scripts/release.py: // major*10000 + minor*100 + patch. Do not edit by hand. - versionCode = 501 - versionName = "0.5.1" + versionCode = 502 + versionName = "0.5.2" ndk { - abiFilters += listOf("arm64-v8a") + abiFilters += listOf(evengineAbi) } } diff --git a/platform/android/game-shell/root.nut b/platform/android/game-shell/root.nut index 892cd95f..abf02919 100644 --- a/platform/android/game-shell/root.nut +++ b/platform/android/game-shell/root.nut @@ -1,4 +1,4 @@ -print("EVEngine v0.5.1") +print("EVEngine v0.5.2") print("-----------------------------------------") diff --git a/platform/ios/game-shell/root.nut b/platform/ios/game-shell/root.nut index 93ff7af1..d7105bdc 100644 --- a/platform/ios/game-shell/root.nut +++ b/platform/ios/game-shell/root.nut @@ -1,3 +1,3 @@ -print("EVEngine v0.5.1") +print("EVEngine v0.5.2") print("-----------------------------------------") diff --git a/scripts/analyze_build.py b/scripts/analyze_build.py index bddcd6fd..4344b127 100644 --- a/scripts/analyze_build.py +++ b/scripts/analyze_build.py @@ -9,6 +9,7 @@ import json import re from dataclasses import dataclass from pathlib import Path +import utf8_stdio SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".h", ".hh", ".hpp", ".hxx"} @@ -159,4 +160,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/architecture_contracts.json b/scripts/architecture_contracts.json index 2751a863..60444ead 100644 --- a/scripts/architecture_contracts.json +++ b/scripts/architecture_contracts.json @@ -21,6 +21,31 @@ "nodiscard_policy": "Critical handles, subscriptions, scopes and operation Results are [[nodiscard]].", "pointer_policy": "A public pointer requires Doxygen ownership/lifetime wording or an explicit reviewed adapter contract." }, + { + "id": "gts-terrain-collider-link-contract", + "rule": "link", + "scope": "src/modules/procgen/physics/GtsTerrainColliderRuntime.cpp", + "owner": "procgen_physics::GtsTerrainColliderRuntime and physics::World3D", + "issue": "EVENGINE-ARCH-LINK-CONTRACTS", + "reason": "Optional generated terrain colliders retain only owner-qualified runtime handles across frame and teardown boundaries.", + "expiry": "2027-12-31", + "evidence": [ + "src/modules/procgen/physics/GtsTerrainColliderRuntime.h", + "docs/usr/modules/procgen_physics.md" + ], + "tests": [ + "test/gts_terrain_collider.cpp", + "scripts/tests/test_architecture_contracts.py" + ], + "symbols": [ + "ColliderLink" + ], + "create": "replace creates World3D-owned static bodies and publishes world/body handles only after every tile collider succeeds.", + "ownership": "World3D solely owns bodies and shapes; the runtime owns only typed process-local links.", + "destroy_order": "Runtime-first destroys resolved bodies; world-first removes the world registry entry and all later resolutions return stale null.", + "restore": "Links are never persisted; callers rebuild them by replacing from the restored GtsTerrainLodSet.", + "stale": "World and body generation-qualified handles are both resolved before any access or destruction." + }, { "id": "physics-link-contract", "rule": "link", diff --git a/scripts/capture_hex_terrain_3d.sh b/scripts/capture_hex_terrain_3d.sh new file mode 100644 index 00000000..4850709e --- /dev/null +++ b/scripts/capture_hex_terrain_3d.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Capture a reproducible frame of the interactive hex map example. +# +# The example captures its own frame with the engine-owned `gfx.saveFramePng` +# path (readback inside the renderer) instead of grabbing the X server, so this +# wrapper only needs to start it headlessly and collect the result. +# +# This is the capture helper for `examples/hex-terrain-3d` (the `hexmap` module's +# interactive example). `scripts/capture_hex_terrain.sh` is the unrelated one for +# the `mesh.hexterrain` procgen example in `examples/hex-terrain`. +# +# Usage: +# scripts/capture_hex_terrain_3d.sh [output.png] +# +# Environment: +# HEX_TERRAIN_3D_CAPTURE_DELAY seconds to let the example run before collecting +# the captured frame (default 8) +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_dir=$(CDPATH= cd -- "$script_dir/.." && pwd) +game_dir="$repo_dir/examples/hex-terrain-3d" +default_output="$game_dir/hex-map.png" +output=${1:-"$default_output"} + +if [ "${HEX_TERRAIN_3D_CAPTURE_INSIDE_XVFB:-0}" != 1 ]; then + HEX_TERRAIN_3D_CAPTURE_INSIDE_XVFB=1 exec xvfb-run -a \ + --server-args="-screen 0 800x600x24" "$0" "$output" +fi + +export ALSOFT_DRIVERS=null +export VK_ICD_FILENAMES=${VK_ICD_FILENAMES:-/usr/share/vulkan/icd.d/lvp_icd.json} + +if [ -x "$repo_dir/build/linux-debug/src/engine/eve" ]; then + eve_bin="$repo_dir/build/linux-debug/src/engine/eve" +elif [ -x "$repo_dir/build/win32-debug/src/engine/eve.exe" ]; then + eve_bin="$repo_dir/build/win32-debug/src/engine/eve.exe" +else + echo "capture_hex_terrain_3d: no engine binary found (build linux-debug or win32-debug)" >&2 + exit 1 +fi + +rm -f "$default_output" +cd "$game_dir" +"$eve_bin" run > /tmp/hexmap-3d-capture.log 2>&1 & +engine_pid=$! +cleanup() { + kill -TERM "$engine_pid" 2>/dev/null || true + sleep 1 + kill -KILL "$engine_pid" 2>/dev/null || true + wait "$engine_pid" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +sleep "${HEX_TERRAIN_3D_CAPTURE_DELAY:-8}" + +if [ ! -f "$default_output" ]; then + echo "capture_hex_terrain_3d: the example did not capture a frame; see /tmp/hexmap-3d-capture.log" >&2 + exit 1 +fi + +if [ "$output" != "$default_output" ]; then + cp "$default_output" "$output" +fi +echo "capture_hex_terrain_3d: wrote $output" diff --git a/scripts/check_architecture_contracts.py b/scripts/check_architecture_contracts.py index 0a4a5d35..f5043f6e 100644 --- a/scripts/check_architecture_contracts.py +++ b/scripts/check_architecture_contracts.py @@ -40,6 +40,7 @@ from dataclasses import dataclass from datetime import date, datetime from pathlib import Path from typing import Any, Iterable, Mapping +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent DEFAULT_METADATA = ROOT / "scripts" / "architecture_contracts.json" @@ -284,6 +285,10 @@ def _git(args: list[str]) -> str: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + # git writes UTF-8; the locale default (cp936 on a Chinese Windows) + # would mangle or reject a non-ASCII path instead. + encoding="utf-8", + errors="replace", ) except (OSError, subprocess.CalledProcessError): return "" @@ -673,4 +678,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/check_binding_gap_metadata.py b/scripts/check_binding_gap_metadata.py index 6937755e..935117af 100644 --- a/scripts/check_binding_gap_metadata.py +++ b/scripts/check_binding_gap_metadata.py @@ -13,6 +13,7 @@ import json import re import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent GAPS = ROOT / "scripts" / "check_bindings_gaps.txt" @@ -71,4 +72,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/check_bindings.py b/scripts/check_bindings.py index 591a7e3a..f1aa14d9 100644 --- a/scripts/check_bindings.py +++ b/scripts/check_bindings.py @@ -36,6 +36,7 @@ import sys from pathlib import Path import check_module_manifest +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent MODULES = ROOT / "src" / "modules" @@ -224,4 +225,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/check_examples.py b/scripts/check_examples.py new file mode 100644 index 00000000..f29bf3ac --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Enforce the examples/ layout contract. + +Every directory under ``examples/`` is one of two reviewed kinds: + +* a **runnable example** -- it owns ``main.nut`` plus a ``config.nut`` that + assigns the engine ``config`` table, and it is executed by + ``scripts/smoke_examples.sh`` in CI; or +* a **non-runnable sample** -- a C++/plugin/asset package that is explicitly + listed in :data:`NON_RUNNABLE_EXAMPLES` with the reason it cannot be started + by ``eve run``. + +Both kinds must own a ``README.md``, and every example must appear in the +``examples/README.md`` overview so the directory stays discoverable. + +The runtime half of the contract lives in ``scripts/smoke_examples.sh``: every +directory that this gate classifies as runnable is launched for at least +``MIN_RUN_SECONDS`` and must still be alive, with no error markers, when the +harness kills it. A new example therefore cannot reach ``dev`` without either +surviving that smoke run or being added to the reviewed exemption list here. + +Usage:: + + python3 scripts/check_examples.py + python3 scripts/check_examples.py --json + python3 scripts/check_examples.py --list-runnable + python3 scripts/check_examples.py --print-missing-rows + python3 scripts/check_examples.py --changed-base origin/dev +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, Mapping +import utf8_stdio + +REPO = Path(__file__).resolve().parent.parent +EXAMPLES_DIR = REPO / "examples" +INDEX_FILE = EXAMPLES_DIR / "README.md" + +# Directories that intentionally cannot be started as an ``eve run`` project. +# Every entry needs a reason; a stale entry (missing directory) and an entry +# whose directory grew a main.nut both fail this gate. +NON_RUNNABLE_EXAMPLES: Mapping[str, str] = { + "agent": "C++ 强化学习示例:main.cpp + train.nut,随 CMake 构建,不是 eve 可运行项目。", + "ai-editor": "AI 生成编辑器的演示资料(editor_demo.py + editors/*.editor.json),通过 eve mcp 无头主机运行。", + "economy": "单文件 economy 模块脚本演示,用 eve run -r examples/economy/economy_demo.nut 运行,没有项目级 config/main。", + "live2d-backend-plugin": "Live2D 后端替换插件的 C++ 骨架(CMakeLists.txt),随宿主工程编译,不是 eve 可运行项目。", + "native-plugin": "SDK 原生插件示例(CMakeLists.txt + hello_plugin.cpp),由 CMake 构建后再被脚本加载。", + "shader_effect_package": "打包好的 shader effect 资产包(effect.vert/frag + parameters.json),供其它示例引用。", + "surface-fluid-dynamic": "C++ 侧流体示例(main.cpp),随构建系统编译,不是 eve 可运行项目。", +} + +# ``config = { ... }`` / ``config <- { ... }`` or a direct field assignment. +CONFIG_ASSIGN_RE = re.compile(r"^\s*config\s*(?:<-|=)(?!=)", re.MULTILINE) +CONFIG_FIELD_RE = re.compile(r"^\s*config\.[A-Za-z_]", re.MULTILINE) +INDEX_LINK_RE = re.compile(r"\]\(([^)]+?)/README\.md\)") +HEADING_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE) + + +@dataclass(frozen=True) +class Example: + """One directory directly under ``examples/``.""" + + name: str + path: Path + runnable: bool + + @property + def main(self) -> Path: + return self.path / "main.nut" + + @property + def config(self) -> Path: + return self.path / "config.nut" + + @property + def readme(self) -> Path: + return self.path / "README.md" + + @property + def relative(self) -> str: + return f"examples/{self.name}" + + +def discover(root: Path = REPO) -> list[Example]: + """Return every example directory, sorted by name.""" + examples_dir = root / "examples" + if not examples_dir.is_dir(): + return [] + return [ + Example(name=child.name, path=child, runnable=(child / "main.nut").is_file()) + for child in sorted(examples_dir.iterdir()) + if child.is_dir() + ] + + +def linked_examples(index_text: str) -> list[str]: + """Return the example names linked from the overview README, in file order.""" + names: list[str] = [] + for raw in INDEX_LINK_RE.findall(index_text): + name = raw.strip().removeprefix("./").rstrip("/") + if "/" in name or not name or name.startswith(".."): + continue + names.append(name) + return names + + +def assigns_config_table(config_text: str) -> bool: + """True when ``config.nut`` actually overrides the engine ``config`` table.""" + return bool(CONFIG_ASSIGN_RE.search(config_text) or CONFIG_FIELD_RE.search(config_text)) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def index_path(root: Path = REPO) -> Path: + return root / "examples" / "README.md" + + +def index_exists(root: Path = REPO) -> bool: + return index_path(root).is_file() + + +def read_index_text(root: Path = REPO) -> str: + path = index_path(root) + return read_text(path) if path.is_file() else "" + + +def readme_title(path: Path) -> str: + """First ``# heading`` of an example README, or the directory name.""" + if not path.is_file(): + return path.parent.name + match = HEADING_RE.search(read_text(path)) + if not match: + return path.parent.name + return match.group(1).lstrip("# ").strip() + + +def conformance_findings( + root: Path = REPO, + exemptions: Mapping[str, str] | None = None, +) -> list[str]: + """Return every examples/ layout violation as a human-readable line.""" + exemptions = NON_RUNNABLE_EXAMPLES if exemptions is None else exemptions + examples = discover(root) + if not examples: + return [f"error: no example directories found under {root / 'examples'}"] + + findings: list[str] = [] + by_name = {example.name: example for example in examples} + + for example in examples: + if not example.readme.is_file(): + findings.append(f"{example.relative}/README.md: missing example README") + + if example.runnable: + if not example.config.is_file(): + findings.append( + f"{example.relative}/config.nut: runnable example has no config.nut" + ) + elif not assigns_config_table(read_text(example.config)): + findings.append( + f"{example.relative}/config.nut: does not assign the engine `config` " + "table, so window size/title are silently ignored" + ) + if not read_text(example.main).strip(): + findings.append(f"{example.relative}/main.nut: empty") + elif example.name not in exemptions: + findings.append( + f"{example.relative}/: has no main.nut and is not listed in " + "NON_RUNNABLE_EXAMPLES in scripts/check_examples.py; either make it a " + "runnable example (main.nut + config.nut) or exempt it with a reason" + ) + + for name in sorted(exemptions): + reason = exemptions[name] + example = by_name.get(name) + if example is None: + findings.append( + f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' has no " + "matching examples/ directory" + ) + continue + if not reason.strip(): + findings.append( + f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' needs a reason" + ) + if example.runnable: + findings.append( + f"scripts/check_examples.py: NON_RUNNABLE_EXAMPLES entry '{name}' is stale: " + "the directory now owns main.nut and is smoke-testable" + ) + + linked = linked_examples(read_index_text(root)) + linked_set = set(linked) + if not index_exists(root): + findings.append("examples/README.md: missing example overview") + for name in linked: + if name not in by_name: + findings.append( + f"examples/README.md: links examples/{name}/README.md but that directory " + "does not exist" + ) + for example in examples: + if example.name not in linked_set: + findings.append( + f"examples/README.md: {example.relative} is not listed in the overview table" + ) + return findings + + +def missing_index_rows(root: Path = REPO) -> list[str]: + """Markdown table rows for examples missing from the overview README.""" + linked = set(linked_examples(read_index_text(root))) + return [ + f"| [{example.name}]({example.name}/README.md) | {readme_title(example.readme)} |" + for example in discover(root) + if example.name not in linked + ] + + +def changed_example_dirs(base: str, root: Path = REPO) -> list[str]: + """Example directory names added or modified between ``base`` and HEAD.""" + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}...HEAD", "--", "examples"], + cwd=root, + check=False, + capture_output=True, + text=True, + # git writes UTF-8; the locale default (cp936 on a Chinese Windows) + # would mangle or reject a non-ASCII path instead. + encoding="utf-8", + errors="replace", + ) + if result.returncode != 0: + return [] + names: set[str] = set() + for line in result.stdout.splitlines(): + parts = Path(line.strip()).parts + if len(parts) >= 2 and parts[0] == "examples": + names.add(parts[1]) + return sorted(names) + + +def main(argv: Iterable[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=REPO, help=argparse.SUPPRESS) + parser.add_argument("--json", action="store_true", help="emit findings as JSON") + parser.add_argument( + "--list-runnable", + action="store_true", + help="print the runnable examples that scripts/smoke_examples.sh must launch", + ) + parser.add_argument( + "--print-missing-rows", + action="store_true", + help="print the overview rows needed for unregistered examples", + ) + parser.add_argument( + "--changed-base", + help="git revision to diff against; reports which examples this change adds", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + + examples = discover(args.root) + + if args.list_runnable: + for example in examples: + if example.runnable: + print(example.name) + return 0 + + if args.print_missing_rows: + for row in missing_index_rows(args.root): + print(row) + return 0 + + if args.changed_base: + changed = changed_example_dirs(args.changed_base, args.root) + known = {example.name for example in examples} + runnable = {example.name for example in examples if example.runnable} + print(f"changed example directories ({args.changed_base}...HEAD): {len(changed)}") + for name in changed: + if name not in known: + continue + if name in runnable: + print(f" smoke-covered examples/{name}") + else: + print(f" exempt examples/{name} (NON_RUNNABLE_EXAMPLES)") + new = [name for name in changed if name not in known] + if new: + print(f" removed {', '.join(new)}") + + findings = conformance_findings(args.root) + if args.json: + print(json.dumps({"ok": not findings, "findings": findings}, ensure_ascii=False)) + return 1 if findings else 0 + + if findings: + for finding in findings: + print(f"error: {finding}", file=sys.stderr) + print( + f"error: {len(findings)} example contract violation(s); see the " + "\"示例规范\" section of examples/README.md", + file=sys.stderr, + ) + return 1 + + runnable = sum(1 for example in examples if example.runnable) + exempt = sum(1 for example in examples if not example.runnable) + print( + f"examples layout OK: {len(examples)} examples " + f"({runnable} runnable, {exempt} reviewed non-runnable)" + ) + return 0 + + +if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() + sys.exit(main()) diff --git a/scripts/check_module_manifest.py b/scripts/check_module_manifest.py index c9e8cd27..961dbfa0 100644 --- a/scripts/check_module_manifest.py +++ b/scripts/check_module_manifest.py @@ -23,6 +23,7 @@ import re import sys from dataclasses import asdict, dataclass from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent MANIFEST = ROOT / "cmake" / "module_manifest.cmake" @@ -270,4 +271,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/check_nodiscard.py b/scripts/check_nodiscard.py index 3d3773bb..28708609 100644 --- a/scripts/check_nodiscard.py +++ b/scripts/check_nodiscard.py @@ -16,6 +16,7 @@ import shutil import subprocess import sys import tempfile +import utf8_stdio FIXTURES = { @@ -128,4 +129,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/check_quality_metadata.py b/scripts/check_quality_metadata.py index 6a6d2a1a..f005aaa0 100644 --- a/scripts/check_quality_metadata.py +++ b/scripts/check_quality_metadata.py @@ -37,6 +37,7 @@ from dataclasses import asdict, dataclass from datetime import date from pathlib import Path from typing import Any, Iterable, Mapping +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent DEFAULT_METADATA = ROOT / "scripts" / "quality_debt_allowlist.json" @@ -443,4 +444,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/check_resource_format_exports.py b/scripts/check_resource_format_exports.py index f0763708..31358159 100644 --- a/scripts/check_resource_format_exports.py +++ b/scripts/check_resource_format_exports.py @@ -11,6 +11,7 @@ import struct import subprocess from PIL import Image +import utf8_stdio def verify(directory): @@ -55,10 +56,11 @@ def main(): except (OSError, ValueError, subprocess.SubprocessError) as error: result = {'status': 'failed', 'error': str(error)} args.report.parent.mkdir(parents=True, exist_ok=True) - args.report.write_text(json.dumps(result, indent=2) + '\n') + args.report.write_text(json.dumps(result, indent=2) + '\n', encoding="utf-8") print(json.dumps(result)) return 0 if result['status'] == 'passed' else 1 if __name__ == '__main__': + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/check_snow_example.py b/scripts/check_snow_example.py index 2bc16c0f..915d23f0 100644 --- a/scripts/check_snow_example.py +++ b/scripts/check_snow_example.py @@ -3,6 +3,7 @@ import argparse import os from pathlib import Path import subprocess +import utf8_stdio def main(): @@ -43,4 +44,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/check_test_manifest.py b/scripts/check_test_manifest.py index 55d4564a..71ea21dc 100644 --- a/scripts/check_test_manifest.py +++ b/scripts/check_test_manifest.py @@ -13,6 +13,7 @@ from __future__ import annotations import re import sys from pathlib import Path +import utf8_stdio REPO = Path(__file__).resolve().parent.parent TEST_DIR = REPO / "test" @@ -82,4 +83,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/ci_change_scope.py b/scripts/ci_change_scope.py index 5b7e80c0..35c3255f 100644 --- a/scripts/ci_change_scope.py +++ b/scripts/ci_change_scope.py @@ -6,6 +6,7 @@ from __future__ import annotations import argparse import subprocess from pathlib import Path +import utf8_stdio BUILD_SCOPES = ("windows", "android", "macos", "ios", "linux", "asan", "fuzz", "webgpu") SCOPES = (*BUILD_SCOPES, "tools") @@ -50,6 +51,10 @@ def changed_paths(base: str, head: str) -> list[str]: check=True, capture_output=True, text=True, + # git writes UTF-8; the locale default (cp936 on a Chinese Windows) + # would mangle or reject a non-ASCII path instead. + encoding="utf-8", + errors="replace", ) return [line for line in result.stdout.splitlines() if line] @@ -77,4 +82,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/compare_render_backends.py b/scripts/compare_render_backends.py index 41a6b6da..dca6ef04 100644 --- a/scripts/compare_render_backends.py +++ b/scripts/compare_render_backends.py @@ -17,6 +17,7 @@ try: from scripts.ci_result import CIResult, FailureKind except ModuleNotFoundError: # Direct execution sets sys.path to scripts/. from ci_result import CIResult, FailureKind +import utf8_stdio class ArtifactContractError(ValueError): @@ -227,4 +228,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/compile_decal_shaders.py b/scripts/compile_decal_shaders.py index 7f141a1e..d27c9355 100644 --- a/scripts/compile_decal_shaders.py +++ b/scripts/compile_decal_shaders.py @@ -13,6 +13,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -99,4 +100,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_gpu_driven_compute_shaders.py b/scripts/compile_gpu_driven_compute_shaders.py index ab83e88b..f01e2d07 100644 --- a/scripts/compile_gpu_driven_compute_shaders.py +++ b/scripts/compile_gpu_driven_compute_shaders.py @@ -12,6 +12,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -79,5 +80,6 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() import shutil sys.exit(main()) diff --git a/scripts/compile_graphics_aa_shaders.py b/scripts/compile_graphics_aa_shaders.py index 30c2ab50..83184b57 100644 --- a/scripts/compile_graphics_aa_shaders.py +++ b/scripts/compile_graphics_aa_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -22,6 +23,7 @@ FRAGS = [ "exposure_adapt.frag", "exposure_apply.frag", "depth_pyramid_downsample.frag", + "depth_of_field.frag", "aa_nfaa.frag", "aa_taa.frag", ] @@ -75,4 +77,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_ao_shaders.py b/scripts/compile_graphics_ao_shaders.py index e1ac06ea..b7d5a174 100644 --- a/scripts/compile_graphics_ao_shaders.py +++ b/scripts/compile_graphics_ao_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -69,4 +70,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_dof_shaders.py b/scripts/compile_graphics_dof_shaders.py index ff8e5f4b..0de93319 100644 --- a/scripts/compile_graphics_dof_shaders.py +++ b/scripts/compile_graphics_dof_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -64,4 +65,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_gi_shaders.py b/scripts/compile_graphics_gi_shaders.py index 786d2f12..462ddb58 100644 --- a/scripts/compile_graphics_gi_shaders.py +++ b/scripts/compile_graphics_gi_shaders.py @@ -8,6 +8,7 @@ import subprocess import sys import shutil from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -73,4 +74,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_outline_shaders.py b/scripts/compile_graphics_outline_shaders.py index 41d23162..b9c0dfc6 100644 --- a/scripts/compile_graphics_outline_shaders.py +++ b/scripts/compile_graphics_outline_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -66,4 +67,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_ssr_shaders.py b/scripts/compile_graphics_ssr_shaders.py index c4eda027..51dc5f34 100644 --- a/scripts/compile_graphics_ssr_shaders.py +++ b/scripts/compile_graphics_ssr_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import shutil from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -59,4 +60,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/compile_graphics_volumetric_shaders.py b/scripts/compile_graphics_volumetric_shaders.py index 90734113..585ec958 100644 --- a/scripts/compile_graphics_volumetric_shaders.py +++ b/scripts/compile_graphics_volumetric_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -17,6 +18,7 @@ FRAGS = [ "volumetric_fog.frag", "volumetric_froxel.frag", "volumetric_cloud.frag", + "volumetric_directional_cookie.frag", ] @@ -68,4 +70,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_graphics_water_shaders.py b/scripts/compile_graphics_water_shaders.py index eb6d2701..0d386058 100644 --- a/scripts/compile_graphics_water_shaders.py +++ b/scripts/compile_graphics_water_shaders.py @@ -6,6 +6,7 @@ from __future__ import annotations import struct import subprocess from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -52,4 +53,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/compile_mesh3d_shaders.py b/scripts/compile_mesh3d_shaders.py index 3b86f9f4..4fa6d512 100644 --- a/scripts/compile_mesh3d_shaders.py +++ b/scripts/compile_mesh3d_shaders.py @@ -16,6 +16,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -42,6 +43,8 @@ SHADERS = [ ("mesh3d_gbuffer_skin_alpha.frag", "frag"), ("mesh3d_shadow_skin.vert", "vert"), ("mesh3d_shadow_skin_alpha.frag", "frag"), + ("mesh3d_shadow_alpha.vert", "vert"), + ("mesh3d_shadow_alpha.frag", "frag"), ("mesh3d_gbuffer_vis.vert", "vert"), ("mesh3d_gbuffer_vis.frag", "frag"), ("mesh3d_gbuffer_vgvis.vert", "vert"), @@ -131,4 +134,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_particle_gpu_shaders.py b/scripts/compile_particle_gpu_shaders.py index b61a614c..73ca02b7 100644 --- a/scripts/compile_particle_gpu_shaders.py +++ b/scripts/compile_particle_gpu_shaders.py @@ -8,6 +8,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -63,4 +64,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_stylize_shaders.py b/scripts/compile_stylize_shaders.py index 0d3198db..da6d6d7c 100644 --- a/scripts/compile_stylize_shaders.py +++ b/scripts/compile_stylize_shaders.py @@ -7,6 +7,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "stylize" / "shaders" @@ -82,4 +83,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_virtualgeometry_shaders.py b/scripts/compile_virtualgeometry_shaders.py index 2a21e21b..bf265799 100644 --- a/scripts/compile_virtualgeometry_shaders.py +++ b/scripts/compile_virtualgeometry_shaders.py @@ -15,6 +15,7 @@ import os import subprocess import sys import struct +import utf8_stdio ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SHADERS = os.path.join(ROOT, "src", "modules", "virtualgeometry", "shaders") @@ -58,7 +59,7 @@ def emit_inc(spv, inc, symbol): lines.append(" " + ", ".join("0x%08x" % w for w in chunk) + ",") lines.append("};") lines.append("static const size_t %s_count = sizeof(%s) / sizeof(%s[0]);" % (symbol, symbol, symbol)) - with open(inc, "w") as f: + with open(inc, "w", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") @@ -87,4 +88,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_voxel_shaders.py b/scripts/compile_voxel_shaders.py index 6c2ffd2a..93709b9b 100644 --- a/scripts/compile_voxel_shaders.py +++ b/scripts/compile_voxel_shaders.py @@ -8,6 +8,7 @@ import subprocess import sys import shutil from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADER_DIR = ROOT / "src" / "modules" / "graphics" / "shaders" @@ -70,4 +71,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_waterfall_shader.py b/scripts/compile_waterfall_shader.py index 62ee10ab..53601be4 100644 --- a/scripts/compile_waterfall_shader.py +++ b/scripts/compile_waterfall_shader.py @@ -11,6 +11,7 @@ import struct import subprocess import sys from pathlib import Path +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SRC = ROOT / "src" / "modules" / "graphics" / "shaders" / "waterfall_mesh.frag" @@ -57,4 +58,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/compile_weather_shaders.py b/scripts/compile_weather_shaders.py index 62954059..3dc8c6df 100644 --- a/scripts/compile_weather_shaders.py +++ b/scripts/compile_weather_shaders.py @@ -5,6 +5,7 @@ import shutil import struct import subprocess import tempfile +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] SHADERS = ROOT / "src/modules/weather/shaders" @@ -33,4 +34,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/compile_webgpu_shaders.py b/scripts/compile_webgpu_shaders.py index 00fa9807..51627043 100644 --- a/scripts/compile_webgpu_shaders.py +++ b/scripts/compile_webgpu_shaders.py @@ -26,6 +26,7 @@ import subprocess import sys import tempfile from pathlib import Path +import utf8_stdio STAGE_EXT = {".vert": "vertex", ".frag": "fragment", ".glsl": "fragment"} @@ -138,4 +139,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/download_spine_models.py b/scripts/download_spine_models.py index d318c347..b0f9c927 100644 --- a/scripts/download_spine_models.py +++ b/scripts/download_spine_models.py @@ -18,6 +18,7 @@ Idempotent: skips files already present with the expected size. Writes a import os import sys import urllib.request +import utf8_stdio RAW_BASE = ( "https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/3.5" @@ -124,4 +125,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/export_meshret_onnx.py b/scripts/export_meshret_onnx.py index 185c7da2..baef2d5e 100644 --- a/scripts/export_meshret_onnx.py +++ b/scripts/export_meshret_onnx.py @@ -18,6 +18,7 @@ from __future__ import annotations import argparse from pathlib import Path +import utf8_stdio def build_identity_onnx(path: Path, joints: int = 8, sensors: int = 16, pairs: int = 8, frames: int = 4) -> None: @@ -71,4 +72,5 @@ def main() -> None: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/export_tiny_gpt2.py b/scripts/export_tiny_gpt2.py index 9ba5c043..f7a38a6f 100644 --- a/scripts/export_tiny_gpt2.py +++ b/scripts/export_tiny_gpt2.py @@ -31,6 +31,7 @@ import zipfile import collections import numpy as np +import utf8_stdio ROOT = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(os.path.dirname(ROOT), "test", "assets", "mini_llm") @@ -294,7 +295,7 @@ def main(): sys.exit(f"missing {torch_bin} (or model.safetensors): download a tiny GPT-2 first") vocab, ranks = load_bpe(os.path.join(SRC, f"{MODEL_PREFIX}_vocab.json"), os.path.join(SRC, f"{MODEL_PREFIX}_merges.txt")) - with open(os.path.join(SRC, f"{MODEL_PREFIX}_config.json")) as f: + with open(os.path.join(SRC, f"{MODEL_PREFIX}_config.json"), encoding="utf-8") as f: cfg = json.load(f) if cfg.get("n_inner") is None: cfg["n_inner"] = 4 * cfg["n_embd"] @@ -324,4 +325,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/generate_binding_contracts.py b/scripts/generate_binding_contracts.py index bb2ebcbe..6155c72a 100644 --- a/scripts/generate_binding_contracts.py +++ b/scripts/generate_binding_contracts.py @@ -16,6 +16,7 @@ import json from pathlib import Path import re import sys +import utf8_stdio ROOT = Path(__file__).resolve().parents[1] @@ -576,4 +577,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/generate_building_starter_assets.py b/scripts/generate_building_starter_assets.py index 94836b0c..de8bb621 100644 --- a/scripts/generate_building_starter_assets.py +++ b/scripts/generate_building_starter_assets.py @@ -60,4 +60,4 @@ ImageDraw.Draw(im).polygon([(32, 0), (63, 23), (32, 45), (0, 23)], fill=(255, 25 im.save(out / 'cursor.png') mapping = {n: i + 1 for i, n in enumerate(files)} v = {n: list(range(121 + i * 4, 125 + i * 4)) for i, n in enumerate(variants)} -(out / 'catalog.nut').write_text('c14Gids <- {' + ','.join(('[%d]=%d' % p for p in mapping.items())) + '};\nc14Variants <- {' + ','.join(('[%d]=[%s]' % (n, ','.join(map(str, g))) for n, g in v.items())) + '};\n') +(out / 'catalog.nut').write_text('c14Gids <- {' + ','.join(('[%d]=%d' % p for p in mapping.items())) + '};\nc14Variants <- {' + ','.join(('[%d]=[%s]' % (n, ','.join(map(str, g))) for n, g in v.items())) + '};\n', encoding='utf-8') diff --git a/scripts/generate_resource_format_fixtures.py b/scripts/generate_resource_format_fixtures.py index f107a66c..4204afa7 100644 --- a/scripts/generate_resource_format_fixtures.py +++ b/scripts/generate_resource_format_fixtures.py @@ -14,6 +14,7 @@ import math import struct from PIL import Image, __version__ as pillow_version +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent / 'test/fixtures/resource_formats' @@ -57,11 +58,11 @@ def main(): for ext, codec in [('flac', 'flac'), ('ogg', 'libvorbis'), ('mp3', 'libmp3lame')]: ffmpeg('-i', 'tone.wav', '-c:a', codec, 'tone.' + ext) triangle = [(0., 0., 0.), (2., 0., 0.), (0., 3., 0.)] - (ROOT / 'triangle.obj').write_text('v 0 0 0\nv 2 0 0\nv 0 3 0\nf 1 2 3\n') - (ROOT / 'triangle.stl').write_text('solid triangle\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 2 0 0\nvertex 0 3 0\nendloop\nendfacet\nendsolid triangle\n') - (ROOT / 'triangle.ply').write_text('ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n2 0 0\n0 3 0\n3 0 1 2\n') - (ROOT / 'triangle.off').write_text('OFF\n3 1 0\n0 0 0\n2 0 0\n0 3 0\n3 0 1 2\n') - (ROOT / 'triangle.x').write_text('xof 0303txt 0032\nMesh triangle {\n3;\n0.0;0.0;0.0;,\n2.0;0.0;0.0;,\n0.0;3.0;0.0;;\n1;\n3;0,1,2;;\n}\n') + (ROOT / 'triangle.obj').write_text('v 0 0 0\nv 2 0 0\nv 0 3 0\nf 1 2 3\n', encoding='utf-8') + (ROOT / 'triangle.stl').write_text('solid triangle\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 2 0 0\nvertex 0 3 0\nendloop\nendfacet\nendsolid triangle\n', encoding='utf-8') + (ROOT / 'triangle.ply').write_text('ply\nformat ascii 1.0\nelement vertex 3\nproperty float x\nproperty float y\nproperty float z\nelement face 1\nproperty list uchar int vertex_indices\nend_header\n0 0 0\n2 0 0\n0 3 0\n3 0 1 2\n', encoding='utf-8') + (ROOT / 'triangle.off').write_text('OFF\n3 1 0\n0 0 0\n2 0 0\n0 3 0\n3 0 1 2\n', encoding='utf-8') + (ROOT / 'triangle.x').write_text('xof 0303txt 0032\nMesh triangle {\n3;\n0.0;0.0;0.0;,\n2.0;0.0;0.0;,\n0.0;3.0;0.0;;\n1;\n3;0,1,2;;\n}\n', encoding='utf-8') model_bytes = b''.join(struct.pack(' 2026-09-10T00:00:00Z2026-09-10T00:00:00ZY_UP @@ -86,7 +87,7 @@ def main(): -''') +''', encoding='utf-8') version = subprocess.run(['ffmpeg', '-version'], capture_output=True, text=True, check=True).stdout.splitlines()[0] manifest = {'schema': 'evengine.resource-format-fixtures', 'version': 1, 'license': 'CC0-1.0', 'source': 'Original synthetic pixels and PCM generated by this repository script', @@ -94,8 +95,9 @@ def main(): 'image': {'width': 5, 'height': 3, 'rgba8': pixels}, 'audio': {'sampleRate': 44100, 'channels': 2, 'sampleFrames': 11025, 'leftHz': 440, 'rightHz': 880}, 'sha256': {p.name: hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(ROOT.glob('*')) if p.suffix in ['.png', '.tga', '.bmp', '.jpg', '.jpeg', '.gif', '.webp', '.exr', '.hdr', '.wav', '.flac', '.ogg', '.mp3', '.obj', '.stl', '.ply', '.off', '.x', '.gltf', '.glb', '.dae']}} - (ROOT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + (ROOT / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') if __name__ == '__main__': + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/migrate_evescript.py b/scripts/migrate_evescript.py index c8d067da..f4f72197 100644 --- a/scripts/migrate_evescript.py +++ b/scripts/migrate_evescript.py @@ -22,6 +22,7 @@ from dataclasses import dataclass from pathlib import Path import re import sys +import utf8_stdio EXCLUDED_DIRS = {".git", "build", "third-party"} @@ -224,4 +225,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/module_depgraph.py b/scripts/module_depgraph.py index df861446..25fd6108 100755 --- a/scripts/module_depgraph.py +++ b/scripts/module_depgraph.py @@ -25,6 +25,7 @@ import sys from collections import defaultdict import check_module_manifest +import utf8_stdio REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MODULES_DIR = os.path.join(REPO, "src", "modules") @@ -289,4 +290,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/pack_sprite_stack_atlas.py b/scripts/pack_sprite_stack_atlas.py index 12d3fd31..bf5938bc 100644 --- a/scripts/pack_sprite_stack_atlas.py +++ b/scripts/pack_sprite_stack_atlas.py @@ -20,6 +20,7 @@ import struct import sys import zlib from pathlib import Path +import utf8_stdio PNG_SIG = b"\x89PNG\r\n\x1a\n" @@ -162,4 +163,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/package_shader.py b/scripts/package_shader.py index c73636df..95720402 100644 --- a/scripts/package_shader.py +++ b/scripts/package_shader.py @@ -11,6 +11,7 @@ from pathlib import Path import struct import subprocess import tempfile +import utf8_stdio def main(): @@ -47,4 +48,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/profile_matrix.py b/scripts/profile_matrix.py index 08830a9b..e8397875 100644 --- a/scripts/profile_matrix.py +++ b/scripts/profile_matrix.py @@ -37,6 +37,7 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "scripts")) import check_module_manifest as manifest # noqa: E402 +import utf8_stdio # noqa: E402 PROFILES = ( @@ -466,4 +467,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/release.py b/scripts/release.py index bcdb8821..69fbb1e2 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -9,6 +9,7 @@ import sys from dataclasses import dataclass from pathlib import Path from typing import Callable +import utf8_stdio TAG_RE = re.compile(r"^v([0-9]+)\.([0-9]+)\.([0-9]+)$") PROMOTE_HEAD_RE = re.compile(r"^promote/v[0-9]+\.[0-9]+\.[0-9]+$") @@ -819,6 +820,7 @@ def main( if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/resource_format_suite.py b/scripts/resource_format_suite.py index 705b8c0d..cd4d2190 100644 --- a/scripts/resource_format_suite.py +++ b/scripts/resource_format_suite.py @@ -13,6 +13,7 @@ import subprocess import sys import tempfile import xml.etree.ElementTree as ET +import utf8_stdio ROOT = Path(__file__).resolve().parent.parent FAMILIES = { @@ -39,14 +40,15 @@ def inventory(root=ROOT): extensions = [] loader = {"image": "image/ImageLoader.cpp", "sound": "sound/SoundLoader.cpp", "model": "model3d/ModelLoader.cpp"}.get(family) if loader: - extensions = sorted(set(re.findall(r'ext == "(\.[^"]+)"', (root / "src/modules" / loader).read_text()))) + source = (root / "src/modules" / loader).read_text(encoding="utf-8") + extensions = sorted(set(re.findall(r'ext == "(\.[^"]+)"', source))) result[family] = {"advertised_resource_extensions": extensions, "tests": cases} return result def fixture_errors(root=ROOT): directory = root / "test/fixtures/resource_formats" - manifest = json.loads((directory / "manifest.json").read_text()) + manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8")) if manifest.get("schema") != "evengine.resource-format-fixtures" or manifest.get("version") != 1: raise ValueError("unsupported fixture manifest schema/version") errors = [] @@ -60,7 +62,8 @@ def fixture_errors(root=ROOT): def format_coverage(groups, results, root=ROOT): - contract = json.loads((root / "scripts/resource_format_contracts.json").read_text()) + contracts_path = root / "scripts/resource_format_contracts.json" + contract = json.loads(contracts_path.read_text(encoding="utf-8")) if (set(contract) != {"schema", "version", "unknownFields", "formats"} or contract["schema"] != "evengine.resource-format-contracts" or contract["version"] != 1 or contract["unknownFields"] != "reject"): @@ -179,7 +182,9 @@ def main(): report.update(status="failed", error=str(error)) code = 1 args.report.parent.mkdir(parents=True, exist_ok=True) - args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n") + args.report.write_text( + json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) print(f"{report['status']}: {sum(len(g['tests']) for g in groups.values())} source tests; {args.report}") if report.get("error"): print(report["error"], file=sys.stderr) @@ -189,4 +194,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/run_kokoro_onnx.py b/scripts/run_kokoro_onnx.py index b5058c10..c845fb2a 100644 --- a/scripts/run_kokoro_onnx.py +++ b/scripts/run_kokoro_onnx.py @@ -16,6 +16,7 @@ import wave import numpy as np import onnx +import utf8_stdio def main(): @@ -93,4 +94,5 @@ def main(): if __name__ == '__main__': + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/serve_webgpu.py b/scripts/serve_webgpu.py index c14252f0..5678f69f 100644 --- a/scripts/serve_webgpu.py +++ b/scripts/serve_webgpu.py @@ -16,6 +16,7 @@ import http.server import os import sys from pathlib import Path +import utf8_stdio class PlaygroundHandler(http.server.SimpleHTTPRequestHandler): @@ -72,4 +73,5 @@ def main(): if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() sys.exit(main()) diff --git a/scripts/smoke_examples.sh b/scripts/smoke_examples.sh index 603bce0b..a0ad58f7 100644 --- a/scripts/smoke_examples.sh +++ b/scripts/smoke_examples.sh @@ -2,14 +2,22 @@ # Smoke-test every runnable example under examples/. # # Each example is launched as `eve run` from inside its own directory (the same -# thing `make run GAME=examples/` does) and killed after a few seconds. -# Its stdout/stderr is scanned for known error markers; a clean run reports -# PASS, otherwise FAIL with the offending lines. +# thing `make run GAME=examples/` does). The harness enforces two things +# before it kills the process: +# +# 1. the example must still be running after MIN_RUN_SECONDS_PER seconds, so a +# project that dies during startup can never be reported as a pass; and +# 2. its stdout/stderr must be free of the known error markers below. +# +# A process that exits on its own is reported instead of being silently accepted: +# a clean exit after the minimum window passes with an explicit note, while dying +# inside the window or exiting with a non-zero code fails the example. # # Usage: # scripts/smoke_examples.sh # all examples # scripts/smoke_examples.sh basic tree-generator # only these examples # EVE=.../eve.exe RUN_SECONDS=8 scripts/smoke_examples.sh +# MIN_RUN_SECONDS=2 RUN_SECONDS=6 scripts/smoke_examples.sh # # Exit status is non-zero if any example failed. @@ -31,6 +39,15 @@ fi RUN_SECONDS_PER="${RUN_SECONDS:-6}" +# Every example must survive at least this long: "started, rendered and did not +# die" is the contract `scripts/check_examples.py` points at for new examples. +MIN_RUN_SECONDS_PER="${MIN_RUN_SECONDS:-2}" + +if [[ "$MIN_RUN_SECONDS_PER" -gt "$RUN_SECONDS_PER" ]]; then + echo "note: MIN_RUN_SECONDS=$MIN_RUN_SECONDS_PER > RUN_SECONDS=$RUN_SECONDS_PER; raising the run window" >&2 + RUN_SECONDS_PER="$MIN_RUN_SECONDS_PER" +fi + # Extra args appended to every `eve run` invocation (e.g. EVE_RUN_ARGS="--debug"). RUN_ARGS="${EVE_RUN_ARGS:-}" @@ -54,7 +71,9 @@ failed to load: EOF mapfile -t MARKERS <<<"$MARKERS_SCRIPT" -# Runnable examples = dirs containing a main.nut. +# Runnable examples = dirs containing a main.nut. scripts/check_examples.py +# guarantees that every non-exempt example directory has one, so this set is the +# complete runtime surface. if [[ $# -gt 0 ]]; then EXAMPLES=("$@") else @@ -65,6 +84,8 @@ else done fi +echo "Smoke: ${#EXAMPLES[@]} example(s), alive window ${RUN_SECONDS_PER}s, minimum ${MIN_RUN_SECONDS_PER}s, binary ${EVE_BIN}" + FAILED=0 PASSED=0 declare -a FAILED_NAMES=() @@ -80,57 +101,107 @@ for name in "${EXAMPLES[@]}"; do fi log="$(mktemp)" - if is_windows; then - # GNU timeout on MSYS cannot reliably terminate the native Windows GUI - # process (SIGTERM is ignored), so launch in background and force-kill by - # image name with taskkill. - ( - cd "$dir" || exit 1 - "$EVE_BIN" run $extra >"$log" 2>&1 & - pid=$! - sleep "$RUN_SECONDS_PER" - taskkill //F //IM "$(basename "$EVE_BIN")" >/dev/null 2>&1 || true + use_timeout=0 + if ! is_windows && command -v timeout >/dev/null 2>&1; then + use_timeout=1 + fi + + if [[ "$use_timeout" -eq 1 ]]; then + # `exec` makes $! the timeout process itself, so liveness of the timeout is + # liveness of the example. + ( cd "$dir" && exec timeout -k 3 "$RUN_SECONDS_PER" "$EVE_BIN" run $extra >"$log" 2>&1 ) & + else + # Windows: GNU timeout on MSYS cannot reliably terminate the native GUI + # process (SIGTERM is ignored), so launch in the background and force-kill + # by image name with taskkill below. + ( cd "$dir" && exec "$EVE_BIN" run $extra >"$log" 2>&1 ) & + fi + pid=$! + + sleep "$MIN_RUN_SECONDS_PER" + early_exit=0 + killed_by_harness=0 + if ! kill -0 "$pid" 2>/dev/null; then + # Died inside the minimum window: always a failure, whatever the exit code. + early_exit=1 + wait "$pid" 2>/dev/null + rc=$? + else + remaining=$((RUN_SECONDS_PER - MIN_RUN_SECONDS_PER)) + [[ "$remaining" -gt 0 ]] && sleep "$remaining" + if [[ "$use_timeout" -eq 0 ]] && kill -0 "$pid" 2>/dev/null; then + killed_by_harness=1 + if is_windows; then + taskkill //F //IM "$(basename "$EVE_BIN")" >/dev/null 2>&1 || true + fi kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true - ) + fi + wait "$pid" 2>/dev/null + rc=$? + if [[ "$killed_by_harness" -eq 0 && "$use_timeout" -eq 1 ]]; then + # `timeout` reports 124 (SIGTERM) / 137 (SIGKILL) / 143 when it stopped a + # healthy process; anything else means the example ended by itself. + case "$rc" in + 124|137|143) killed_by_harness=1 ;; + esac + fi + fi + + # Reset the per-example verdict: `reason` drives failure, `note` explains a + # clean self-exit. Both must be rebuilt every iteration. + reason="" + note="" + if [[ "$early_exit" -eq 1 ]]; then + reason="died inside the ${MIN_RUN_SECONDS_PER}s liveness window (exit code ${rc})" + elif [[ "$killed_by_harness" -eq 0 ]]; then + if [[ "$rc" -eq 0 ]]; then + note="exited on its own before the ${RUN_SECONDS_PER}s window ended" else - if command -v timeout >/dev/null 2>&1; then - ( cd "$dir" && timeout -k 3 "$RUN_SECONDS_PER" "$EVE_BIN" run $extra >"$log" 2>&1 ) - else - ( cd "$dir" && "$EVE_BIN" run $extra >"$log" 2>&1 ) & - pid=$! - sleep "$RUN_SECONDS_PER" - kill "$pid" 2>/dev/null || true - wait "$pid" 2>/dev/null || true + reason="exited on its own before the ${RUN_SECONDS_PER}s window ended (exit code ${rc})" fi fi hit="" - for m in "${MARKERS[@]}"; do - if grep -qF "$m" "$log"; then - hit="$m" - break - fi - done + if [[ -z "$reason" ]]; then + for m in "${MARKERS[@]}"; do + if grep -qF "$m" "$log"; then + hit="$m" + break + fi + done + [[ -n "$hit" ]] && reason="marker: $hit" + fi - if [[ -n "$hit" ]]; then - echo "FAIL $name (marker: $hit)" - grep -nF "$hit" "$log" 2>/dev/null | head -3 | sed 's/^/ /' + if [[ -n "$reason" ]]; then + echo "FAIL $name (${reason})" + if [[ -n "$hit" ]]; then + # Print the marker WITH context: a marker line alone hides the rest of a + # multi-line message (e.g. a compiler diagnostic on the following line), + # which is exactly what a CI reader needs to debug the failure. + grep -n -A 8 -F "$hit" "$log" 2>/dev/null | head -24 | sed 's/^/ /' + fi echo " --- key output (non-frame-error lines, head 30) ---" grep -vF "frame error" "$log" 2>/dev/null | head -n 30 | sed 's/^/ | /' - echo " --- example output (last 10 lines) ---" - tail -n 10 "$log" | sed 's/^/ | /' + echo " --- example output (last 30 lines) ---" + tail -n 30 "$log" | sed 's/^/ | /' FAILED=$((FAILED + 1)) FAILED_NAMES+=("$name") else - echo "PASS $name" + if [[ -n "$note" ]]; then + echo "PASS $name ($note)" + else + echo "PASS $name (alive through the ${RUN_SECONDS_PER}s window)" + fi + if ! grep -qF "first present" "$log"; then + echo " note: no 'first present' startup line in the captured log" + fi PASSED=$((PASSED + 1)) fi rm -f "$log" done echo -echo "Summary: $PASSED passed, $FAILED failed" +echo "Summary: $PASSED passed, $FAILED failed (each example must stay alive >= ${MIN_RUN_SECONDS_PER}s with no error markers)" if [[ $FAILED -gt 0 ]]; then printf 'Failed: %s\n' "${FAILED_NAMES[*]}" exit 1 diff --git a/scripts/sort_architecture_contracts.py b/scripts/sort_architecture_contracts.py index 6712bb8a..906839eb 100644 --- a/scripts/sort_architecture_contracts.py +++ b/scripts/sort_architecture_contracts.py @@ -8,6 +8,7 @@ import json from pathlib import Path from check_architecture_contracts import DEFAULT_METADATA, catalogue_sort_key +import utf8_stdio def main() -> int: @@ -36,4 +37,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/test_tensor_onnx.py b/scripts/test_tensor_onnx.py index 5351fae2..37885aa1 100644 --- a/scripts/test_tensor_onnx.py +++ b/scripts/test_tensor_onnx.py @@ -16,6 +16,7 @@ import onnx from onnx import helper as h, numpy_helper as nh from onnx.reference import ReferenceEvaluator import onnxruntime as ort +import utf8_stdio def main(): @@ -148,4 +149,5 @@ def main(): if __name__=='__main__': + utf8_stdio.enable_utf8_stdio() main() diff --git a/scripts/tests/test_analyze_build.py b/scripts/tests/test_analyze_build.py index 51d37d03..5f76d7b7 100644 --- a/scripts/tests/test_analyze_build.py +++ b/scripts/tests/test_analyze_build.py @@ -4,6 +4,10 @@ from pathlib import Path SCRIPT = Path(__file__).parents[1] / "analyze_build.py" +# Loading a script by path bypasses the normal sys.path entry for its directory, +# so its own sibling imports (utf8_stdio) only resolve if the test puts that +# directory on sys.path itself. +sys.path.insert(0, str(SCRIPT.parent)) SPEC = importlib.util.spec_from_file_location("analyze_build", SCRIPT) MODULE = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None diff --git a/scripts/tests/test_check_examples.py b/scripts/tests/test_check_examples.py new file mode 100644 index 00000000..5f999379 --- /dev/null +++ b/scripts/tests/test_check_examples.py @@ -0,0 +1,158 @@ +"""Tests for the examples/ layout contract enforced by scripts/check_examples.py.""" + +import shutil +import sys +import tempfile +import unittest +import uuid +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts")) + +import check_examples # noqa: E402 + +MAIN_NUT = "eve_init = function() {}\n" +CONFIG_NUT = "config <- { width = 800 height = 600 }\n" + + +def scratch_parent() -> Path: + """A writable scratch parent, preferring the (git-ignored) build/ directory.""" + build = ROOT / "build" + try: + build.mkdir(parents=True, exist_ok=True) + except OSError: + return Path(tempfile.gettempdir()) + return build + + +class ExampleLayoutTests(unittest.TestCase): + def setUp(self): + self.root = scratch_parent() / f"check-examples-{uuid.uuid4().hex[:8]}" + (self.root / "examples").mkdir(parents=True) + self.index = self.root / "examples" / "README.md" + + def tearDown(self): + shutil.rmtree(self.root, ignore_errors=True) + + def add_example(self, name, *, main=True, config=CONFIG_NUT, readme=True): + path = self.root / "examples" / name + path.mkdir(parents=True, exist_ok=True) + if main: + (path / "main.nut").write_text(MAIN_NUT, encoding="utf-8") + if config is not None: + (path / "config.nut").write_text(config, encoding="utf-8") + if readme: + (path / "README.md").write_text(f"# {name}\n\n说明\n", encoding="utf-8") + return path + + def write_index(self, names, extra=""): + rows = "\n".join(f"| [{name}]({name}/README.md) | 说明 |" for name in names) + self.index.write_text( + f"# 示例总览\n\n{extra}\n| 示例 | 演示能力 |\n|---|---|\n{rows}\n", + encoding="utf-8", + ) + + def findings(self, exemptions=None): + return check_examples.conformance_findings(self.root, exemptions or {}) + + def assert_reports(self, needle, exemptions=None): + findings = self.findings(exemptions) + self.assertTrue( + any(needle in finding for finding in findings), + f"expected a finding containing {needle!r}, got {findings}", + ) + + def test_conforming_tree_reports_nothing(self): + self.add_example("alpha") + self.add_example("beta", main=False, config=None) + self.write_index(["alpha", "beta"]) + self.assertEqual([], self.findings({"beta": "C++ plugin sample"})) + + def test_runnable_example_needs_config_nut(self): + self.add_example("alpha", config=None) + self.write_index(["alpha"]) + self.assert_reports("alpha/config.nut: runnable example has no config.nut") + + def test_config_nut_must_assign_the_config_table(self): + self.add_example("alpha", config='window_title <- "alpha";\nwindow_width <- 960;\n') + self.write_index(["alpha"]) + self.assert_reports("alpha/config.nut: does not assign the engine `config` table") + + def test_config_field_assignment_is_accepted(self): + self.add_example("alpha", config="config.width <- 1024;\nconfig.title <- 'alpha';\n") + self.write_index(["alpha"]) + self.assertEqual([], self.findings()) + + def test_every_example_needs_a_readme(self): + self.add_example("alpha", readme=False) + self.write_index(["alpha"]) + self.assert_reports("alpha/README.md: missing example README") + + def test_directory_without_main_nut_must_be_exempted(self): + self.add_example("orphan", main=False, config=None) + self.write_index(["orphan"]) + self.assert_reports("examples/orphan/: has no main.nut") + + def test_exemption_without_reason_is_reported(self): + self.add_example("beta", main=False, config=None) + self.write_index(["beta"]) + self.assert_reports("NON_RUNNABLE_EXAMPLES entry 'beta' needs a reason", {"beta": " "}) + + def test_stale_exemption_is_reported(self): + self.add_example("alpha") + self.write_index(["alpha"]) + self.assert_reports( + "NON_RUNNABLE_EXAMPLES entry 'ghost' has no matching examples/ directory", + {"ghost": "removed long ago"}, + ) + + def test_exemption_for_a_runnable_example_is_reported(self): + self.add_example("alpha") + self.write_index(["alpha"]) + self.assert_reports("entry 'alpha' is stale", {"alpha": "now runnable"}) + + def test_example_missing_from_the_overview_is_reported(self): + self.add_example("alpha") + self.add_example("beta") + self.write_index(["alpha"]) + self.assert_reports("examples/beta is not listed in the overview table") + + def test_dead_index_link_is_reported(self): + self.add_example("alpha") + self.write_index(["alpha", "ghost"]) + self.assert_reports("links examples/ghost/README.md but that directory does not exist") + + def test_missing_overview_file_is_reported(self): + self.add_example("alpha") + self.assert_reports("examples/README.md: missing example overview") + + def test_linked_examples_reads_only_example_readme_links(self): + text = ( + "[Character Motion Lab](character-motion-lab/README.md)\n" + "[docs](../Readme.md)\n" + "[nested](tools/gizmo/README.md)\n" + "[index](README.md)\n" + ) + self.assertEqual(["character-motion-lab"], check_examples.linked_examples(text)) + + def test_missing_index_rows_are_ready_to_paste(self): + self.add_example("alpha") + self.write_index([]) + self.assertEqual( + ["| [alpha](alpha/README.md) | alpha |"], + check_examples.missing_index_rows(self.root), + ) + + def test_repository_examples_satisfy_the_contract(self): + self.assertEqual([], check_examples.conformance_findings(ROOT)) + + def test_repository_exemptions_describe_real_directories(self): + discovered = {example.name for example in check_examples.discover(ROOT)} + for name, reason in check_examples.NON_RUNNABLE_EXAMPLES.items(): + self.assertIn(name, discovered) + self.assertTrue(reason.strip(), f"{name} needs a reason") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ci_release_timeouts.py b/scripts/tests/test_ci_release_timeouts.py new file mode 100644 index 00000000..b6c57156 --- /dev/null +++ b/scripts/tests/test_ci_release_timeouts.py @@ -0,0 +1,91 @@ +"""Contracts for CI cold-build and release job budgets.""" + +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" + + +def job_block(workflow: str, job: str) -> str: + start = workflow.index(f" {job}:\n") + match = re.search(r"^ [a-z0-9-]+:\n", workflow[start + 1 :], re.MULTILINE) + if match is None: + return workflow[start:] + return workflow[start : start + 1 + match.start()] + + +def step_timeout(job: str, step: str, workflow: str) -> int: + block = job_block(workflow, job) + match = re.search( + rf"^ - name: {re.escape(step)}\n(?:.*\n){{0,8}}?" + r"^ timeout-minutes: (\d+)$", + block, + re.MULTILINE, + ) + if match is None: + raise AssertionError(f"missing timeout for {job} / {step}") + return int(match.group(1)) + + +class ReleaseJobTimeoutTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow = WORKFLOW.read_text(encoding="utf-8") + + def test_release_job_budgets_cover_cold_builds_and_tests(self) -> None: + minimums = { + "windows-release": 240, + "android-release": 150, + "macos-release": 150, + "linux-release": 150, + } + for job, minimum in minimums.items(): + with self.subTest(job=job): + block = job_block(self.workflow, job) + match = re.search(r"^ timeout-minutes: (\d+)$", block, re.MULTILINE) + self.assertIsNotNone(match) + self.assertGreaterEqual(int(match.group(1)), minimum) + + def test_cold_native_build_steps_have_observed_headroom(self) -> None: + minimums = { + ("windows", "Build win32 debug"): 100, + ("windows-release", "Build win32 release"): 160, + ("android", "Build android debug"): 85, + ("macos", "Build macosx debug"): 75, + ("macos", "Run macosx unit tests"): 60, + ("macos", "Build native Dawn Metal lane"): 75, + } + for (job, step), minimum in minimums.items(): + with self.subTest(job=job, step=step): + self.assertGreaterEqual(step_timeout(job, step, self.workflow), minimum) + + def test_multi_phase_job_budgets_cover_observed_cold_runs(self) -> None: + minimums = { + "windows": 300, + "android": 120, + "macos": 240, + "linux-coverage": 150, + } + for job, minimum in minimums.items(): + with self.subTest(job=job): + block = job_block(self.workflow, job) + match = re.search(r"^ timeout-minutes: (\d+)$", block, re.MULTILINE) + self.assertIsNotNone(match) + self.assertGreaterEqual(int(match.group(1)), minimum) + + def test_android_jobs_start_sccache_before_the_build(self) -> None: + for job in ("android", "android-release"): + with self.subTest(job=job): + block = job_block(self.workflow, job) + start = block.index(" - name: Start C/C++ compilation cache\n") + build_name = "android debug" if job == "android" else "android release" + build = block.index(f" - name: Build {build_name}\n") + self.assertLess(start, build) + self.assertIn(" run: sccache --start-server\n", block[start:build]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_migrate_evescript.py b/scripts/tests/test_migrate_evescript.py index a93e7b51..5515149d 100644 --- a/scripts/tests/test_migrate_evescript.py +++ b/scripts/tests/test_migrate_evescript.py @@ -5,6 +5,10 @@ import unittest ROOT = Path(__file__).resolve().parents[2] +# Loading a script by path bypasses the normal sys.path entry for its directory, +# so its own sibling imports (utf8_stdio) only resolve if the test puts that +# directory on sys.path itself. +sys.path.insert(0, str(ROOT / "scripts")) SPEC = importlib.util.spec_from_file_location("migrate_evescript", ROOT / "scripts" / "migrate_evescript.py") MODULE = importlib.util.module_from_spec(SPEC) assert SPEC.loader is not None diff --git a/scripts/tests/test_mobile_sdk_release_gates.py b/scripts/tests/test_mobile_sdk_release_gates.py new file mode 100644 index 00000000..b667efa1 --- /dev/null +++ b/scripts/tests/test_mobile_sdk_release_gates.py @@ -0,0 +1,121 @@ +"""Static contracts for release-blocking Android and iOS SDK validation.""" + +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "sdk-release.yml" +SDK_TEST = ROOT / ".github" / "scripts" / "test-sdk.sh" +APK_RUNNER = ROOT / ".github" / "scripts" / "run-consumer-apks.sh" +CONSUMER_TEST = ROOT / ".github" / "scripts" / "consumer-test.sh" +SDK_INSTALL = ROOT / "cmake" / "install_sdk.cmake" +THIRD_PARTY_BUILD = ROOT / "cmake" / "third_party_build.cmake" +ANDROID_GRADLE = ROOT / "platform" / "android" / "apk" / "app" / "build.gradle.kts" +VULKAN_GRAPHICS = ROOT / "src" / "modules" / "graphics" / "vulkan" / "Graphics.cpp" +SCRIPT_RUNTIME = ROOT / "src" / "engine" / "common" / "Runtime.cpp" + + +def job_block(workflow: str, job: str, next_job: str | None = None) -> str: + start = workflow.index(f" {job}:\n") + if next_job is None: + return workflow[start:] + end = workflow.index(f" {next_job}:\n", start) + return workflow[start:end] + + +class MobileSdkReleaseGateTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.workflow = WORKFLOW.read_text(encoding="utf-8") + cls.sdk_test = SDK_TEST.read_text(encoding="utf-8") + cls.apk_runner = APK_RUNNER.read_text(encoding="utf-8") + cls.consumer_test = CONSUMER_TEST.read_text(encoding="utf-8") + cls.sdk_install = SDK_INSTALL.read_text(encoding="utf-8") + cls.third_party_build = THIRD_PARTY_BUILD.read_text(encoding="utf-8") + cls.android_gradle = ANDROID_GRADLE.read_text(encoding="utf-8") + cls.vulkan_graphics = VULKAN_GRAPHICS.read_text(encoding="utf-8") + cls.script_runtime = SCRIPT_RUNTIME.read_text(encoding="utf-8") + + def test_android_emulator_is_a_hard_gate_for_every_consumer_host(self) -> None: + block = job_block(self.workflow, "run-android", "ios-sim") + self.assertNotIn("continue-on-error:", block) + self.assertIn("needs: [consumer-win32, consumer-linux, consumer-macosx]", block) + self.assertIn("pattern: android-apk-*", block) + self.assertIn("merge-multiple: true", block) + self.assertIn("runs-on: ubuntu-latest", block) + self.assertIn("api-level: 35", block) + self.assertIn("arch: x86_64", block) + self.assertIn("emulator-boot-timeout: 1800", block) + self.assertIn("-gpu swiftshader", block) + self.assertIn("-accel on", block) + self.assertIn("disable-linux-hw-accel: false", block) + self.assertIn("Enable KVM access", block) + self.assertIn("actions/checkout@", block) + self.assertIn("bash .github/scripts/run-consumer-apks.sh apk", block) + self.assertIn('expected_abi="${4:-arm64-v8a}"', self.apk_runner) + self.assertIn('^lib/${expected_abi}/', self.apk_runner) + self.assertIn("^lib/(x86|x86_64)/", self.apk_runner) + self.assertIn("EVE_CI_GAME_OK", self.apk_runner) + self.assertIn('expected_count="${2:-3}"', self.apk_runner) + self.assertIn('"$apksigner" sign', self.apk_runner) + self.assertIn('adb install -r "$signed_apk"', self.apk_runner) + self.assertEqual(3, self.workflow.count("name: android-apk-${{ env.HOST }}")) + + def test_ios_artifact_and_simulator_are_hard_gates(self) -> None: + ios_build = job_block(self.workflow, "ios", "linux") + self.assertIn("test-sdk.sh dist/eve-sdk/ios ios", ios_build) + simulator = job_block(self.workflow, "ios-sim") + self.assertNotIn("continue-on-error:", simulator) + self.assertIn("make build/ios-sim-debug", simulator) + self.assertIn("EVE_CI_GAME_OK", simulator) + + def test_mobile_sdk_checks_do_not_skip_when_prerequisites_are_missing(self) -> None: + self.assertNotIn("SKIP: android APK smoke needs", self.sdk_test) + self.assertIn('fail "android APK smoke needs', self.sdk_test) + self.assertIn('if [ "$PLAT" = "ios" ]', self.sdk_test) + self.assertIn("lipo -archs", self.sdk_test) + + def test_android_sdk_requires_sdl_runtime_at_install_and_consume_time(self) -> None: + self.assertIn('foreach(_eve_required_lib IN ITEMS libSDL2.so)', self.sdk_install) + self.assertIn('message(FATAL_ERROR', self.sdk_install) + self.assertIn('missing lib/$lib', self.sdk_test) + self.assertIn('bash gradlew :app:assembleDebug', self.sdk_test) + self.assertIn('lib/arm64-v8a/libSDL2.so', self.consumer_test) + + def test_x86_android_build_disables_arm_neon_probe(self) -> None: + self.assertIn('ANDROID_ABI MATCHES "^x86(_64)?$"', self.third_party_build) + self.assertIn("-DALSOFT_CPUEXT_NEON=OFF", self.third_party_build) + + def test_android_apk_abi_is_overridable_for_native_emulator_gate(self) -> None: + self.assertIn('gradleProperty("evengineAbi").getOrElse("arm64-v8a")', self.android_gradle) + self.assertIn('abiFilters += listOf(evengineAbi)', self.android_gradle) + self.assertIn('-PevengineAbi="$ANDROID_PACKAGE_ABI"', self.sdk_test) + self.assertIn('ANDROID_ABI STREQUAL "x86_64"', self.sdk_install) + self.assertIn('set(_eve_android_ndk_triple "x86_64-linux-android")', self.sdk_install) + + def test_vulkan_feature_query_matches_negotiated_instance_version(self) -> None: + self.assertIn("vkEnumerateInstanceVersion(&loaderVersion)", self.vulkan_graphics) + self.assertIn("app.apiVersion = apiVersion", self.vulkan_graphics) + self.assertIn( + "negotiatedInstanceApiVersion() >= VK_API_VERSION_1_2", self.vulkan_graphics + ) + self.assertIn( + "if (gpuDrivenCaps_.api12) deviceBuilder.add_pNext(&vk12Enable)", + self.vulkan_graphics, + ) + self.assertIn( + "createInfo.vulkanApiVersion = VK_API_VERSION_1_0", self.vulkan_graphics + ) + + def test_android_script_output_is_observable_in_logcat(self) -> None: + self.assertIn('#include "common/config.h"', self.script_runtime) + self.assertIn('__android_log_vprint(ANDROID_LOG_INFO, "EVEngine"', self.script_runtime) + self.assertIn( + "vm->setPrintFunc(&androidScriptPrint, &androidScriptError)", + self.script_runtime, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_resource_format_suite.py b/scripts/tests/test_resource_format_suite.py index 7395a34a..fba1d7f8 100644 --- a/scripts/tests/test_resource_format_suite.py +++ b/scripts/tests/test_resource_format_suite.py @@ -7,6 +7,11 @@ import tempfile import unittest from unittest.mock import patch +ROOT = Path(__file__).parents[2] +# Loading a script by path bypasses the normal sys.path entry for its directory, +# so its own sibling imports (utf8_stdio) only resolve if the test puts that +# directory on sys.path itself. +sys.path.insert(0, str(ROOT / "scripts")) SPEC = importlib.util.spec_from_file_location("suite", Path(__file__).parents[1] / "resource_format_suite.py") suite = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(suite) @@ -18,7 +23,7 @@ class ResourceSuiteTests(unittest.TestCase): report = Path(directory) / "report.json" with patch.object(sys, "argv", ["suite", "--family", "font", "--report", str(report)] + extra): code = suite.main() - return code, json.loads(report.read_text()) + return code, json.loads(report.read_text(encoding="utf-8")) def test_inventory_is_not_execution(self): code, report = self.invoke([]) @@ -51,9 +56,10 @@ class ResourceSuiteTests(unittest.TestCase): def real_ctest(self, script, properties=""): with tempfile.TemporaryDirectory() as directory: root = Path(directory) - (root / "case.py").write_text(script) + (root / "case.py").write_text(script, encoding="utf-8") (root / "CTestTestfile.cmake").write_text( - f'add_test(example "{Path(sys.executable).as_posix()}" "{(root / "case.py").as_posix()}")\n' + properties) + f'add_test(example "{Path(sys.executable).as_posix()}" "{(root / "case.py").as_posix()}")\n' + properties, + encoding="utf-8") return suite.run_cases(root, ["example"]) def test_real_ctest_success_has_per_case_evidence(self): @@ -88,7 +94,7 @@ class ResourceSuiteTests(unittest.TestCase): fixtures.mkdir(parents=True) (fixtures / "manifest.json").write_text(json.dumps({ "schema": "evengine.resource-format-fixtures", "version": 1, - "sha256": {"pattern.png": "0" * 64}})) + "sha256": {"pattern.png": "0" * 64}}), encoding="utf-8") (fixtures / "pattern.png").write_bytes(b"modified bytes") self.assertEqual(suite.fixture_errors(root), ["pattern.png"]) diff --git a/scripts/tests/test_utf8_stdio.py b/scripts/tests/test_utf8_stdio.py new file mode 100644 index 00000000..4799c002 --- /dev/null +++ b/scripts/tests/test_utf8_stdio.py @@ -0,0 +1,292 @@ +"""Tests for scripts/utf8_stdio.py and the convention it enforces. + +The failure being guarded is Windows-local and depends on redirection: CI runs +the checkers on Linux where stdout is UTF-8, but on a Windows host a *piped* +stdout is encoded with the ANSI code page from the locale -- cp936 (GBK) on a +Chinese Windows. A checker that prints text read back out of the tree (an +example README title containing "↔", for instance) then dies with +``UnicodeEncodeError`` instead of reporting its verdict. + +Two halves are covered here: + +* the helper's behaviour, including a real child process running under a forced + CP936 stdio encoding, and +* the repository conventions -- every ``scripts/*.py`` program (a module with a + ``__main__`` guard) calls the helper, every module that is *not* a program + keeps its import side-effect free by not printing at all, and no script or + script test inherits the host locale for text file I/O. That last rule is the + input-side twin of the stdio problem: ``Path.read_text()`` without + ``encoding=`` decodes UTF-8 repository text as CP936 on a Chinese Windows and + raises ``UnicodeDecodeError``. + +The test-runner side (``python3 -m unittest``) is covered by the ``-X utf8`` +flag on the Makefile/CI invocations, because a test module is imported by the +runner rather than being its own entry point. +""" + +from __future__ import annotations + +import ast +import codecs +import os +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +import utf8_stdio # noqa: E402 + +# Text that CP936 cannot encode, so the encoding in effect is observable. +ARROW_TEXT = "2D \u2194 2.5D" +GBK = "cp936" + + +def have_gbk() -> bool: + try: + codecs.lookup(GBK) + except LookupError: + return False + return True + + +def run_child(source: str) -> subprocess.CompletedProcess[bytes]: + """Run ``source`` in a child interpreter whose stdio encoding is CP936.""" + env = dict(os.environ) + env["PYTHONIOENCODING"] = GBK + env.pop("PYTHONUTF8", None) + parts = [str(SCRIPTS), env.get("PYTHONPATH", "")] + env["PYTHONPATH"] = os.pathsep.join(part for part in parts if part) + return subprocess.run( + [sys.executable, "-c", source], + capture_output=True, + cwd=str(ROOT), + env=env, + check=False, + ) + + +def module_guard(tree: ast.Module) -> ast.If | None: + """The top-level ``if __name__ == "__main__":`` block, when present.""" + for node in tree.body: + if isinstance(node, ast.If) and "__main__" in ast.dump(node.test): + return node + return None + + +def calls_enable_utf8(node: ast.AST) -> bool: + """True when ``node`` contains a call to ``.enable_utf8_stdio()``.""" + for child in ast.walk(node): + if not isinstance(child, ast.Call): + continue + func = child.func + if isinstance(func, ast.Attribute) and func.attr == "enable_utf8_stdio": + return True + return False + + +def prints_or_writes_streams(node: ast.AST) -> list[int]: + """Line numbers of ``print(...)`` / ``sys.stdout`` / ``sys.stderr`` uses.""" + lines: list[int] = [] + for child in ast.walk(node): + if isinstance(child, ast.Call) and isinstance(child.func, ast.Name): + if child.func.id == "print": + lines.append(child.lineno) + elif isinstance(child, ast.Attribute) and child.attr in {"stdout", "stderr"}: + if isinstance(child.value, ast.Name) and child.value.id == "sys": + lines.append(child.lineno) + return sorted(set(lines)) + + +def is_binary_open(node: ast.Call) -> bool: + """True when an ``open(...)`` call explicitly asks for a binary mode.""" + mode = "" + for index, arg in enumerate(node.args): + if index == 1 and isinstance(arg, ast.Constant) and isinstance(arg.value, str): + mode = arg.value + for keyword in node.keywords: + if keyword.arg == "mode" and isinstance(keyword.value, ast.Constant): + mode = str(keyword.value.value) + return any(char in mode for char in "bx") + + +def text_io_without_encoding(tree: ast.Module) -> list[str]: + """Text file I/O that would inherit the host locale encoding. + + ``Image.open`` and ``ZipFile.open`` share the name ``open`` but are not text + file I/O, so attribute calls are ignored; a module-local ``read_text`` helper + is not ``Path.read_text`` and is excluded as well. + """ + local = { + node.name + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + found: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if any(keyword.arg == "encoding" for keyword in node.keywords): + continue + func = node.func + if isinstance(func, ast.Attribute) and func.attr in {"read_text", "write_text"}: + if func.attr not in local: + found.append(f"{node.lineno}: {func.attr}() without encoding") + elif isinstance(func, ast.Name) and func.id == "open" and func.id not in local: + if not is_binary_open(node): + found.append(f"{node.lineno}: open() without encoding") + return sorted(found) + + +class HelperBehaviourTests(unittest.TestCase): + def test_piped_output_is_utf8_under_a_cp936_locale(self): + """With the guard, text CP936 cannot encode survives a CP936 stdout.""" + if not have_gbk(): + self.skipTest(f"{GBK} codec unavailable") + source = ( + "import utf8_stdio\n" + "utf8_stdio.enable_utf8_stdio()\n" + f"print({ARROW_TEXT!r})\n" + ) + result = run_child(source) + self.assertEqual(0, result.returncode, result.stderr.decode("utf-8", "replace")) + self.assertEqual(ARROW_TEXT, result.stdout.decode("utf-8").strip()) + + def test_without_the_guard_the_same_line_aborts(self): + """Pins the motivation: the un-guarded child dies instead of printing.""" + if not have_gbk(): + self.skipTest(f"{GBK} codec unavailable") + result = run_child(f"print({ARROW_TEXT!r})\n") + self.assertNotEqual(0, result.returncode) + self.assertIn(b"UnicodeEncodeError", result.stderr) + + def test_stderr_is_covered_too(self): + if not have_gbk(): + self.skipTest(f"{GBK} codec unavailable") + source = ( + "import sys\n" + "import utf8_stdio\n" + "utf8_stdio.enable_utf8_stdio()\n" + "print('error: ' + %r, file=sys.stderr)\n" % ARROW_TEXT + ) + result = run_child(source) + self.assertEqual(0, result.returncode, result.stderr.decode("utf-8", "replace")) + self.assertIn(ARROW_TEXT, result.stderr.decode("utf-8")) + + def test_enable_is_idempotent(self): + utf8_stdio.enable_utf8_stdio() + utf8_stdio.enable_utf8_stdio() + encoding = (sys.stdout.encoding or "").lower().replace("_", "-") + self.assertTrue(encoding.startswith("utf-8"), encoding) + + def test_plain_streams_without_reconfigure_are_ignored(self): + class Plain: + def write(self, text): + return len(text) + + original_out, original_err = sys.stdout, sys.stderr + try: + sys.stdout = Plain() # type: ignore[assignment] + sys.stderr = Plain() # type: ignore[assignment] + utf8_stdio.enable_utf8_stdio() + finally: + sys.stdout, sys.stderr = original_out, original_err + + def test_a_reconfigure_that_fails_is_not_fatal(self): + class Detached: + def reconfigure(self, **kwargs): + raise ValueError("underlying buffer has been detached") + + original = sys.stdout + try: + sys.stdout = Detached() # type: ignore[assignment] + utf8_stdio.enable_utf8_stdio() + finally: + sys.stdout = original + + +class RepositoryConventionTests(unittest.TestCase): + """scripts/*.py programs must guard their own stdio, and no Python code under + scripts/ may inherit the host locale for text file I/O.""" + + def script_paths(self) -> list[Path]: + return [ + path + for path in sorted(SCRIPTS.glob("*.py")) + if path.name != "utf8_stdio.py" + ] + + def python_paths(self) -> list[Path]: + """Scripts plus their tests: every module that touches repository text.""" + return self.script_paths() + sorted((SCRIPTS / "tests").glob("*.py")) + + def test_no_module_inherits_the_locale_for_text_io(self): + offenders: list[str] = [] + for path in self.python_paths(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for problem in text_io_without_encoding(tree): + offenders.append(f"{path.name} -> {problem}") + self.assertEqual( + [], + offenders, + "text file I/O must pass encoding='utf-8': on a Chinese Windows the " + "locale default is cp936, which mangles or rejects UTF-8 repository " + "text (an unencoded Path.read_text() there broke three tests in " + "scripts/resource_format_suite.py)", + ) + + def test_every_script_program_enables_utf8_stdio(self): + missing: list[str] = [] + for path in self.script_paths(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + guard = module_guard(tree) + if guard is None: + continue + if not calls_enable_utf8(guard): + missing.append(path.name) + self.assertEqual( + [], + missing, + "these scripts/*.py programs must call " + "utf8_stdio.enable_utf8_stdio() in their __main__ block", + ) + + def test_the_guard_is_not_installed_as_an_import_side_effect(self): + offenders: list[str] = [] + for path in self.script_paths(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in tree.body: + if node is module_guard(tree): + continue + if calls_enable_utf8(node): + offenders.append(path.name) + self.assertEqual( + [], + offenders, + "calling utf8_stdio.enable_utf8_stdio() at import time would make " + "importing a checker module reconfigure its importer's stdio", + ) + + def test_modules_without_an_entry_point_do_not_print(self): + """The exemption for non-program modules has to stay justified.""" + offenders: list[str] = [] + for path in self.script_paths(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + if module_guard(tree) is not None: + continue + lines = prints_or_writes_streams(tree) + if lines: + offenders.append(f"{path.name}:{lines[0]}") + self.assertEqual( + [], + offenders, + "a scripts/*.py module that writes to stdout/stderr needs a " + "__main__ guard plus utf8_stdio.enable_utf8_stdio()", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/utf8_stdio.py b/scripts/utf8_stdio.py new file mode 100644 index 00000000..341089a4 --- /dev/null +++ b/scripts/utf8_stdio.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Make a repository script's text streams UTF-8 on every host. + +CI runs these scripts on Linux, where ``sys.stdout`` and ``sys.stderr`` are +UTF-8. A Windows host is not automatically UTF-8: as soon as the output is +redirected -- an IDE terminal, ``> log.txt``, ``| tee``, or a tool capturing the +command -- Python encodes text with the ANSI code page from the locale, which is +cp936 (GBK) on a Chinese Windows. Printing a diagnostic containing a character +that code page cannot represent then raises ``UnicodeEncodeError`` and turns a +checker into a traceback. + +That is reachable, not theoretical: this repository is full of characters cp936 +cannot encode (``↔``, ``²``, ``³``, ``⇒``, ``•``, emoji), and checkers print text +they read back out of the tree -- for example ``scripts/check_examples.py +--print-missing-rows`` prints each example README's own title. + +Call :func:`enable_utf8_stdio` once from a program's entry point, never at import +time, so importing a script module stays free of side effects:: + + import utf8_stdio + + ... + + if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() + raise SystemExit(main()) + +``scripts/tests/test_utf8_stdio.py`` enforces that convention for every program +under ``scripts/``, and pins the failure this module exists to remove. + +Scope: this normalises the process's *text streams*. It cannot change the +locale-derived default that ``open()`` and ``subprocess`` use for *other* text, +so code that decodes a child process or writes a data file must still pass +``encoding="utf-8"`` explicitly; the Makefile/CI paths that run unit tests use +``python3 -X utf8`` so runner-generated text is UTF-8 too. +""" + +from __future__ import annotations + +import sys +from typing import TextIO + +# Codec error handler used by default: unlike "strict" it never raises, so a +# stray surrogate or an unforeseen character cannot abort a checker. Python +# itself uses backslashreplace for the default sys.stderr policy. +DEFAULT_ERRORS = "backslashreplace" + + +def _reconfigure(stream: TextIO, errors: str) -> bool: + """Re-encode one stream as UTF-8; return True when it was reconfigured. + + Streams that cannot be reconfigured are left untouched rather than treated + as an error: a test may have replaced ``sys.stdout`` with a plain object, + and a detached or already-closed stream must not turn into a second + failure while the caller is reporting a first one. + """ + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is None: + return False + try: + reconfigure(encoding="utf-8", errors=errors) + except (ValueError, OSError): + return False + return True + + +def enable_utf8_stdio(errors: str = DEFAULT_ERRORS) -> None: + """Re-encode ``sys.stdout`` and ``sys.stderr`` as UTF-8 for this process. + + Idempotent, and safe to call when the streams are redirected, wrapped or + replaced: a stream that cannot be reconfigured is skipped silently. + + @param errors Codec error handler to install (default + :data:`DEFAULT_ERRORS`, which never raises). + @return None + """ + _reconfigure(sys.stdout, errors) + _reconfigure(sys.stderr, errors) diff --git a/scripts/validate_render_frame.py b/scripts/validate_render_frame.py index bd25100e..c3b40aac 100644 --- a/scripts/validate_render_frame.py +++ b/scripts/validate_render_frame.py @@ -7,6 +7,7 @@ import argparse from pathlib import Path from PIL import Image, ImageStat +import utf8_stdio def validate(path: Path) -> tuple[float, float]: @@ -39,4 +40,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/scripts/verify_webgpu_dependencies.py b/scripts/verify_webgpu_dependencies.py index 1c09c0c2..83d77f95 100644 --- a/scripts/verify_webgpu_dependencies.py +++ b/scripts/verify_webgpu_dependencies.py @@ -12,6 +12,7 @@ try: from scripts.ci_result import CIResult, FailureKind except ModuleNotFoundError: # Direct execution sets sys.path to scripts/. from ci_result import CIResult, FailureKind +import utf8_stdio class DependencyContractError(ValueError): @@ -64,4 +65,5 @@ def main() -> int: if __name__ == "__main__": + utf8_stdio.enable_utf8_stdio() raise SystemExit(main()) diff --git a/src/engine/common/EntitySpatialResolver.cpp b/src/engine/common/EntitySpatialResolver.cpp new file mode 100644 index 00000000..49531790 --- /dev/null +++ b/src/engine/common/EntitySpatialResolver.cpp @@ -0,0 +1,30 @@ +#include "common/EntitySpatialResolver.h" + +#include "common/Capability.h" + +#include + +namespace eve { + +Result resolveEntitySpatialPose(ecs::EntityHandle entity, std::string_view bone) { + std::optional pose; + std::optional failure; + cap::forEachUntil([&](IEntitySpatialProvider* provider) { + auto resolved = provider->resolve(entity, bone); + if (resolved) { + pose = std::move(resolved).takeValue(); + return true; + } + if (resolved.status().code() != StatusCode::NotFound) { + failure = resolved.status(); + return true; + } + return false; + }); + if (pose) return Result::success(std::move(*pose)); + if (failure) return Result::failure(std::move(*failure)); + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "no spatial provider owns the entity", "entity")); +} + +} // namespace eve diff --git a/src/engine/common/EntitySpatialResolver.h b/src/engine/common/EntitySpatialResolver.h new file mode 100644 index 00000000..6cdd76f1 --- /dev/null +++ b/src/engine/common/EntitySpatialResolver.h @@ -0,0 +1,55 @@ +#pragma once + +/** @file EntitySpatialResolver.h @brief Optional ECS-entity to world-pose capability. */ + +#include "common/ECS.h" +#include "common/Export.h" +#include "common/Result.h" + +#include + +namespace eve { + +/** @brief Renderer-neutral owning world pose resolved for an ECS entity. */ +struct EntitySpatialPose { + double positionX = 0.0; + double positionY = 0.0; + double positionZ = 0.0; + double rotationXDegrees = 0.0; + double rotationYDegrees = 0.0; + double rotationZDegrees = 0.0; + double scaleX = 1.0; + double scaleY = 1.0; + double scaleZ = 1.0; +}; + +/** + * @brief One optional domain provider capable of resolving selected ECS entities. + * @remarks Providers are owner-thread-only, invoke no callbacks and return NotFound + * for entity types they do not own. Returned poses own all data. + */ +class EVENGINE_API IEntitySpatialProvider { +public: + static constexpr const char* capabilityName = "eve.entity-spatial-provider"; + virtual ~IEntitySpatialProvider() = default; + + /** + * @brief Resolve a current world pose, optionally at a named bone/socket. + * @param entity Generation-qualified ECS identity resolved during this call. + * @param bone Optional stable bone/socket name; empty requests the entity root. + * @return Owning pose, NotFound for an unsupported entity, or a structured failure. + */ + [[nodiscard]] virtual Result resolve(ecs::EntityHandle entity, + std::string_view bone) const = 0; +}; + +/** + * @brief Resolve through registered providers in deterministic registration order. + * @return First successful pose; the first non-NotFound failure; or NotFound. + * @thread Owner thread only; provider registration must be settled before dispatch. + * @reentrancy Providers must not mutate the capability listener registry. + */ +[[nodiscard]] EVENGINE_API Result resolveEntitySpatialPose(ecs::EntityHandle entity, + std::string_view bone = {}); + +} // namespace eve diff --git a/src/engine/common/FramePresentation.h b/src/engine/common/FramePresentation.h new file mode 100644 index 00000000..2f9c90f5 --- /dev/null +++ b/src/engine/common/FramePresentation.h @@ -0,0 +1,18 @@ +#pragma once + +namespace eve { + +/** @brief Cross-module owner for swapchain presentation cadence. */ +class IFramePresentation { +public: + static constexpr const char* capabilityName = "eve.frame-presentation"; + + virtual ~IFramePresentation() = default; + + /** @brief Apply Pcg VSync count; zero selects uncapped presentation. */ + virtual void setVSyncCount(int count) = 0; + /** @brief Return the retained Pcg VSync count. */ + virtual int getVSyncCount() const noexcept = 0; +}; + +} // namespace eve diff --git a/src/engine/common/PcgPhotoModeApply.h b/src/engine/common/PcgPhotoModeApply.h new file mode 100644 index 00000000..be1e78dd --- /dev/null +++ b/src/engine/common/PcgPhotoModeApply.h @@ -0,0 +1,45 @@ +#pragma once +#include "common/Result.h" +#include +#include +#include + +namespace eve { +/** @brief RGBA payload used by the cross-module photo-mode application contract. */ +struct PhotoModeColor { float r=0, g=0, b=0, a=1; }; +/** @brief Typed payload accepted by authoritative photo-mode domain owners. */ +using PhotoModeValue = std::variant; +/** @brief Authoritative owner selected for a Pcg photo-mode field. */ +enum class PhotoModeDomain { Photo=0, System=1, Graphics=2, Camera=3, Streaming=4, Weather=5, Lighting=6, Water=7, PostFx=8, Terrain=9, Grass=10, Audio=11 }; +/** @brief One reversible field assignment in a photo-mode transaction. */ +struct PhotoModeAssignment { std::string field; PhotoModeDomain domain=PhotoModeDomain::Photo; PhotoModeValue value; }; +/** + * @brief Capability implemented by the composition root that routes assignments to domain owners. + * + * Calls are synchronous on the game thread. Implementations must either apply one assignment fully + * or leave its domain unchanged. They must not retain references to the assignment or invoke scripts. + */ +/** + * @brief Optional per-field provider used by the built-in photo-mode capability router. + * + * Providers register as capability listeners. Exactly one provider must accept an assignment. + */ +/** @brief Named ownership response from a photo-mode field provider. */ +enum class PhotoModeFieldAcceptance { Rejected=0, Accepted=1 }; +class IPhotoModeFieldSink { +public: + static constexpr const char* capabilityName="eve.photo-mode.field"; + virtual ~IPhotoModeFieldSink()=default; + /** @brief Return true only for fields owned by this provider. */ + virtual PhotoModeFieldAcceptance acceptsPhotoModeField(const PhotoModeAssignment& assignment) const noexcept=0; + /** @brief Apply one accepted assignment atomically on the game thread. */ + [[nodiscard]] virtual Result applyPhotoModeField(const PhotoModeAssignment& assignment)=0; +}; +class IPhotoModeApplySink { +public: + static constexpr const char* capabilityName="eve.photo-mode.apply"; + virtual ~IPhotoModeApplySink()=default; + /** @brief Apply one atomic assignment to its authoritative domain. */ + [[nodiscard]] virtual Result applyPhotoModeAssignment(const PhotoModeAssignment& assignment)=0; +}; +} diff --git a/src/engine/common/ProcgenProbeSink.h b/src/engine/common/ProcgenProbeSink.h new file mode 100644 index 00000000..bb899b04 --- /dev/null +++ b/src/engine/common/ProcgenProbeSink.h @@ -0,0 +1,53 @@ +#pragma once + +#include "common/Export.h" +#include "common/Result.h" + +#include +#include +#include +#include + +namespace eve { + +/** @brief Engine-neutral description of one generated reflection or light probe. */ +struct EVENGINE_API ProcgenProbeDesc { + std::uint64_t sourcePointId = 0; + std::string resource; + int type = 0; + float x = 0, y = 0, z = 0; + float extentX = 1, extentY = 1, extentZ = 1; + int resolution = 128; + float clipDistance = 1000; + float shadowDistance = 80; + /** Baked diffuse L0 irradiance used by generated light probes. */ + float irradianceR = 0.12F, irradianceG = 0.12F, irradianceB = 0.14F; + /** Optional directional L0..L2 RGB coefficients; coefficient zero falls back to irradianceRGB. */ + std::array sphericalHarmonics{}; + bool hasSphericalHarmonics = false; +}; + +/** + * @brief Optional graphics consumer for complete generated probe batches. + * Implementations own all runtime resources and replace a batch atomically. Inputs are borrowed only for the call. + */ +class EVENGINE_API IProcgenProbeSink { +public: + static constexpr const char* capabilityName = "IProcgenProbeSink"; + virtual ~IProcgenProbeSink() = default; + /** @brief Atomically create or reconcile a complete stable-identity probe batch. */ + [[nodiscard]] virtual Result replaceProbeBatch(const std::string& batchId, + const std::vector& probes) = 0; + /** @brief Remove a published probe batch and all of its owned capture resources. */ + [[nodiscard]] virtual Result removeProbeBatch(const std::string& batchId) = 0; + /** @brief Advance bounded reflection capture and filtering work for all batches. */ + [[nodiscard]] virtual Result tickProbeBatches(int faceBudget, int filterBudget, int filterSamples) = 0; + /** @brief Return all probes currently owned by one batch. */ + [[nodiscard]] virtual int probeCount(const std::string& batchId) const = 0; + /** @brief Return reflection probes currently owned by one batch. */ + [[nodiscard]] virtual int reflectionProbeCount(const std::string& batchId) const = 0; + /** @brief Return light probes currently owned by one batch. */ + [[nodiscard]] virtual int lightProbeCount(const std::string& batchId) const = 0; +}; + +} // namespace eve diff --git a/src/engine/common/Runtime.cpp b/src/engine/common/Runtime.cpp index 52f91028..84e3ce58 100644 --- a/src/engine/common/Runtime.cpp +++ b/src/engine/common/Runtime.cpp @@ -1,6 +1,7 @@ #include "common/Runtime.h" #include "common/Assert.h" +#include "common/config.h" #include "common/Module.h" #include "common/ReflectScript.h" #include "common/ScriptCompiler.h" @@ -10,15 +11,36 @@ #include #include +#include #include #include #include +#if defined(EVENGINE_ANDROID) +#include +#endif + namespace eve { namespace { thread_local std::vector runtime_stack; +#if defined(EVENGINE_ANDROID) +void androidScriptPrint(HSQUIRRELVM, const SQChar* format, ...) { + va_list args; + va_start(args, format); + __android_log_vprint(ANDROID_LOG_INFO, "EVEngine", format, args); + va_end(args); +} + +void androidScriptError(HSQUIRRELVM, const SQChar* format, ...) { + va_list args; + va_start(args, format); + __android_log_vprint(ANDROID_LOG_ERROR, "EVEngine", format, args); + va_end(args); +} +#endif + const char* stageName(ScriptStage stage) { switch (stage) { case ScriptStage::Compile: return "compile"; @@ -271,7 +293,14 @@ std::unique_ptr createVm(size_t stackSize, ssq::Libs::Flag libraries) { // check must not be the only thing standing between a bad stack size and a // half-constructed runtime. EV_PARAM_CHECK(stackSize > 0, "Runtime stack size must be positive"); - return std::make_unique(stackSize, libraries); + auto vm = std::make_unique(stackSize, libraries); +#if defined(EVENGINE_ANDROID) + // Android has no terminal attached to the app process. Route Squirrel's + // print/error callbacks to logcat so application diagnostics and CI startup + // markers remain observable. + vm->setPrintFunc(&androidScriptPrint, &androidScriptError); +#endif + return vm; } /** Runtime error hook: captures message + full stack before it unwinds. */ diff --git a/src/engine/devtools/McpServer.cpp b/src/engine/devtools/McpServer.cpp index 45f5b2cd..db9c3cf9 100644 --- a/src/engine/devtools/McpServer.cpp +++ b/src/engine/devtools/McpServer.cpp @@ -1664,7 +1664,7 @@ std::string handleInitialize(McpServer& mcp, const std::string& idJson, Poco::JS const std::string resultJson = std::string("{\"protocolVersion\":\"") + mcpJsonEscape(protocol) + "\",\"capabilities\":{\"tools\":{},\"resources\":{},\"prompts\":{}}," - "\"serverInfo\":{\"name\":\"evengine\",\"title\":\"EVEngine MCP\",\"version\":\"0.5.1\"}," + "\"serverInfo\":{\"name\":\"evengine\",\"title\":\"EVEngine MCP\",\"version\":\"0.5.2\"}," "\"instructions\":\"EVEngine MCP for AI-assisted game development. eve_host_* tools create JSON-defined editor " "windows bound to Squirrel ViewModels (MVVM) for AI-crafted terrain/material/event editors.\"}"; return makeResult(idJson, resultJson); diff --git a/src/modules/CMakeLists.txt b/src/modules/CMakeLists.txt index bacda766..456d4104 100644 --- a/src/modules/CMakeLists.txt +++ b/src/modules/CMakeLists.txt @@ -554,9 +554,36 @@ if(_has_procgen OR _eve_procgen_core_profile OR EVENGINE_BUILD_PROCGEN_CORE_CHEC # scene adapters, while the normal EVProcgen target keeps the public facade # and all compatibility implementations unchanged. set(_eve_procgen_core_sources + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/PcgSpawnProgress.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/ArtifactPublish.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/AttributeTable.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/core/ProcgenCore.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainStamp.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainDetailLayer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainDetailPlacement.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainTreePlacement.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainObjectPlacement.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainSpawnPlan.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainSpawnPlanCodec.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainBiomePreset.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainBiomePresetCodec.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainCollisionMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainPolygonMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainProbePlacement.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainBakedMaskCache.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainNoiseMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainSplatmap.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainWorldWorkspace.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainGenerationSession.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/PcgRuntimeStamper.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainImageMask.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainEffect.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainErosion.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainLegacyHydraulic.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainLegacyThermal.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainSediment.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/procgen/heightmap/TerrainWaterField.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/GeneratorRegistry.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/GeneratedArtifact.cpp ${CMAKE_CURRENT_SOURCE_DIR}/procgen/Grid2D.cpp diff --git a/src/modules/action/AbilityAsset.cpp b/src/modules/action/AbilityAsset.cpp new file mode 100644 index 00000000..54672890 --- /dev/null +++ b/src/modules/action/AbilityAsset.cpp @@ -0,0 +1,606 @@ +#include "action/AbilityAsset.h" + +#include +#include +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +const Value* field(const Value& value, std::string_view name) { + if (!value.isObject()) return nullptr; + const auto& object = *value.getIf(); + const auto found = object.find(std::string(name)); + return found == object.end() ? nullptr : &found->second; +} + +Result text(const Value& value, std::string_view name, std::string path) { + const auto* item = field(value, name); + if (!item || !item->isString()) return invalid("expected text field", std::move(path)); + return Result::success(item->asString()); +} + +Result integer(const Value& value, std::string_view name, std::string path) { + const auto* item = field(value, name); + if (!item || !item->isInt64()) return invalid("expected integer field", std::move(path)); + return Result::success(item->asInt()); +} + +Result boolean(const Value& value, std::string_view name, std::string path) { + const auto* item = field(value, name); + if (!item || !item->isBool()) return invalid("expected boolean field", std::move(path)); + return Result::success(item->asBool()); +} + +Result number(const Value& value, std::string_view name, std::string path) { + const auto* item = field(value, name); + if (!item || (!item->isDouble() && !item->isInt64())) + return invalid("expected numeric field", std::move(path)); + const double result = item->isDouble() ? item->asDouble() : static_cast(item->asInt()); + if (!std::isfinite(result)) return invalid("numeric field must be finite", std::move(path)); + return Result::success(result); +} + +Result rejectUnknown(const Value& value, const std::set>& known, + std::string path) { + if (!value.isObject()) return invalid("expected object", std::move(path)); + for (const auto& [name, item] : *value.getIf()) { + (void)item; + if (!known.contains(name)) return invalid("unknown field", path + "." + name); + } + return Result::success(); +} + +std::string_view instancingName(AbilityInstancingPolicy value) { + switch (value) { + case AbilityInstancingPolicy::NonInstanced: return "non_instanced"; + case AbilityInstancingPolicy::PerOwner: return "per_owner"; + case AbilityInstancingPolicy::PerExecution: return "per_execution"; + } + return "unknown"; +} + +Result parseInstancing(std::string_view value) { + if (value == "non_instanced") + return Result::success(AbilityInstancingPolicy::NonInstanced); + if (value == "per_owner") return Result::success(AbilityInstancingPolicy::PerOwner); + if (value == "per_execution") + return Result::success(AbilityInstancingPolicy::PerExecution); + return invalid("unknown ability instancing policy", "instancing"); +} + +std::string_view groupName(AbilityActivationGroup value) { + switch (value) { + case AbilityActivationGroup::Independent: return "independent"; + case AbilityActivationGroup::ExclusiveReplaceable: return "exclusive_replaceable"; + case AbilityActivationGroup::ExclusiveBlocking: return "exclusive_blocking"; + } + return "unknown"; +} + +Result parseGroup(std::string_view value) { + if (value == "independent") return Result::success(AbilityActivationGroup::Independent); + if (value == "exclusive_replaceable") + return Result::success(AbilityActivationGroup::ExclusiveReplaceable); + if (value == "exclusive_blocking") + return Result::success(AbilityActivationGroup::ExclusiveBlocking); + return invalid("unknown ability activation group", "activationGroup"); +} + +std::string_view targetingModeName(TargetingMode value) { + switch (value) { + case TargetingMode::None: return "none"; + case TargetingMode::Explicit: return "explicit"; + case TargetingMode::Query: return "query"; + } + return "unknown"; +} + +Result parseTargetingMode(std::string_view value) { + if (value == "none") return Result::success(TargetingMode::None); + if (value == "explicit") return Result::success(TargetingMode::Explicit); + if (value == "query") return Result::success(TargetingMode::Query); + return invalid("unknown targeting mode", "action.targetingMode"); +} + +Value encodeCondition(const decision::Condition& condition) { + Value::Object object{{"kind", std::string(decision::conditionKindName(condition.kind()))}}; + if (condition.kind() == decision::ConditionKind::All || + condition.kind() == decision::ConditionKind::Any || + condition.kind() == decision::ConditionKind::Not) { + Value::Array children; + for (const auto& child : condition.children()) children.push_back(encodeCondition(child)); + object["children"] = std::move(children); + } + switch (condition.kind()) { + case decision::ConditionKind::Compare: + object["key"] = condition.key(); + object["operator"] = std::string(decision::compareOperatorName(condition.compareOperator())); + object["expected"] = condition.expected(); + break; + case decision::ConditionKind::StateEquals: + object["key"] = condition.key(); + object["expected"] = condition.expected(); + break; + case decision::ConditionKind::HasTag: + case decision::ConditionKind::HasAttribute: + case decision::ConditionKind::HasResource: + case decision::ConditionKind::AuthorityCheck: object["key"] = condition.key(); break; + case decision::ConditionKind::PolicyCall: { + object["key"] = condition.key(); + object["arguments"] = condition.arguments(); + if (condition.scriptDeclaration()) { + Value::Array dependencies; + for (const auto& dependency : condition.scriptDeclaration()->dependencies) + dependencies.emplace_back(dependency); + object["script"] = Value::Object{ + {"name", condition.scriptDeclaration()->name}, + {"dependencies", std::move(dependencies)}, + {"determinism", static_cast(condition.scriptDeclaration()->determinism)}}; + } + break; + } + case decision::ConditionKind::All: + case decision::ConditionKind::Any: + case decision::ConditionKind::Not: break; + } + return Value(std::move(object)); +} + +Result> decodeStrings(const Value& parent, std::string_view name, std::string path); + +Result decodeCondition(const Value& value, const std::string& path) { + auto kind = text(value, "kind", path + ".kind"); + if (!kind) return Result::failure(kind.status()); + auto children = [&]() -> Result> { + const auto* source = field(value, "children"); + if (!source || !source->isArray()) + return invalid>("expected children array", path + ".children"); + std::vector result; + const auto& array = *source->getIf(); + result.reserve(array.size()); + for (std::size_t index = 0; index < array.size(); ++index) { + auto child = decodeCondition(array[index], path + ".children[" + std::to_string(index) + "]"); + if (!child) return Result>::failure(child.status()); + result.push_back(std::move(child).takeValue()); + } + return Result>::success(std::move(result)); + }; + if (kind.value() == "all" || kind.value() == "any" || kind.value() == "not") { + auto decoded = children(); + if (!decoded) return Result::failure(decoded.status()); + if (kind.value() == "all") + return Result::success(decision::Condition::all(std::move(decoded).takeValue())); + if (kind.value() == "any") + return Result::success(decision::Condition::any(std::move(decoded).takeValue())); + if (decoded.value().size() != 1) + return invalid("not condition requires one child", path + ".children"); + return Result::success(decision::Condition::not_(std::move(decoded.value().front()))); + } + auto key = text(value, "key", path + ".key"); + if (!key) return Result::failure(key.status()); + if (kind.value() == "compare") { + auto op = text(value, "operator", path + ".operator"); + const auto* expected = field(value, "expected"); + if (!op || !expected) return invalid("compare condition is incomplete", path); + const std::pair operators[] = { + {"eq", decision::CompareOperator::Equal}, {"ne", decision::CompareOperator::NotEqual}, + {"lt", decision::CompareOperator::Less}, {"le", decision::CompareOperator::LessEqual}, + {"gt", decision::CompareOperator::Greater}, {"ge", decision::CompareOperator::GreaterEqual}}; + for (const auto& [name, parsed] : operators) + if (op.value() == name) + return Result::success( + decision::Condition::compare(key.value(), parsed, *expected)); + return invalid("unknown comparison operator", path + ".operator"); + } + if (kind.value() == "state_equals") { + const auto* expected = field(value, "expected"); + if (!expected) return invalid("state condition is incomplete", path); + return Result::success(decision::Condition::stateEquals(key.value(), *expected)); + } + if (kind.value() == "has_tag") + return Result::success(decision::Condition::hasTag(key.value())); + if (kind.value() == "has_attribute") + return Result::success(decision::Condition::hasAttribute(key.value())); + if (kind.value() == "has_resource") + return Result::success(decision::Condition::hasResource(key.value())); + if (kind.value() == "authority_check") + return Result::success(decision::Condition::authorityCheck(key.value())); + if (kind.value() == "policy_call") { + const auto* arguments = field(value, "arguments"); + if (!arguments) return invalid("policy arguments are missing", path + ".arguments"); + std::optional declaration; + if (const auto* script = field(value, "script")) { + auto name = text(*script, "name", path + ".script.name"); + if (!name) return Result::failure(name.status()); + auto dependencies = decodeStrings(*script, "dependencies", path + ".script.dependencies"); + if (!dependencies) return Result::failure(dependencies.status()); + auto determinism = integer(*script, "determinism", path + ".script.determinism"); + if (!determinism) return Result::failure(determinism.status()); + if (determinism.value() < 0 || determinism.value() > + static_cast( + decision::DeterminismLevel::ExplicitlyNondeterministic)) + return invalid("script condition declaration is invalid", path + ".script"); + declaration = decision::ScriptConditionDeclaration{ + name.value(), std::move(dependencies).takeValue(), + static_cast(determinism.value())}; + } + return Result::success( + decision::Condition::policyCall(key.value(), *arguments, std::move(declaration))); + } + return invalid("unknown condition kind", path + ".kind"); +} + +Value::Array strings(const std::vector& values) { + Value::Array result; + result.reserve(values.size()); + for (const auto& value : values) result.emplace_back(value); + return result; +} + +Result> decodeStrings(const Value& parent, std::string_view name, std::string path) { + const auto* source = field(parent, name); + if (!source || !source->isArray()) return invalid>("expected text array", path); + std::vector result; + for (std::size_t index = 0; index < source->getIf()->size(); ++index) { + const auto& item = source->getIf()->at(index); + if (!item.isString()) + return invalid>("expected text item", + path + "[" + std::to_string(index) + "]"); + result.push_back(item.asString()); + } + return Result>::success(std::move(result)); +} + +Value encodeTargetingSpec(const sensing::TargetingSpec& spec) { + Value::Object result{{"space", std::string(sensing::coordinateSpaceName(spec.space))}, + {"domain", static_cast(spec.domain)}, + {"minCount", static_cast(spec.minCount)}, + {"maxCount", static_cast(spec.maxCount)}, + {"minRange", spec.minRange}, + {"maxRange", std::isfinite(spec.maxRange) ? Value(spec.maxRange) : Value()}, + {"requiredTags", strings(spec.requiredTags)}, + {"excludedTags", strings(spec.excludedTags)}, + {"lineOfSight", spec.lineOfSight == sensing::LineOfSightMode::Required}}; + if (spec.zone) result["zone"] = spec.zone->logicalId().format(); + if (spec.worldArea) { + const auto first = spec.worldArea->first(); + const auto second = spec.worldArea->second(); + result["worldArea"] = Value::Object{ + {"shape", static_cast(spec.worldArea->shape())}, + {"first", Value::Array{first.x(), first.y(), first.z()}}, + {"second", Value::Array{second.x(), second.y(), second.z()}}, + {"radius", spec.worldArea->radius()}}; + } + if (spec.gridArea) { + const auto minimum = spec.gridArea->minimum(); + const auto maximum = spec.gridArea->maximum(); + result["gridArea"] = Value::Object{ + {"shape", static_cast(spec.gridArea->shape())}, + {"minimum", Value::Array{minimum.x(), minimum.y(), minimum.z()}}, + {"maximum", Value::Array{maximum.x(), maximum.y(), maximum.z()}}}; + } + return Value(std::move(result)); +} + +Result> vector3(const Value& value, std::string_view name, std::string path) { + const auto* item = field(value, name); + if (!item || !item->isArray() || item->getIf()->size() != 3) + return invalid>("expected three-component array", std::move(path)); + std::array result{}; + for (std::size_t index = 0; index < result.size(); ++index) { + const auto& component = item->getIf()->at(index); + if (!component.isDouble() && !component.isInt64()) + return invalid>("vector component must be numeric", path); + result[index] = component.isDouble() ? component.asDouble() : static_cast(component.asInt()); + if (!std::isfinite(result[index])) + return invalid>("vector component must be finite", path); + } + return Result>::success(result); +} + +Result> gridVector3(const Value& value, std::string_view name, + std::string path) { + auto decoded = vector3(value, name, path); + if (!decoded) return Result>::failure(decoded.status()); + std::array result{}; + for (std::size_t index = 0; index < result.size(); ++index) { + const double component = decoded.value()[index]; + if (std::trunc(component) != component || + component < static_cast(std::numeric_limits::min()) || + component > static_cast(std::numeric_limits::max())) + return invalid>("grid component must be an int32", path); + result[index] = static_cast(component); + } + return Result>::success(result); +} + +Result decodeTargetingSpec(const Value& value) { + sensing::TargetingSpec result; + auto space = text(value, "space", "action.targetingSpec.space"); + if (!space) return Result::failure(space.status()); + auto domain = integer(value, "domain", "action.targetingSpec.domain"); + if (!domain) return Result::failure(domain.status()); + auto minCount = integer(value, "minCount", "action.targetingSpec.minCount"); + if (!minCount) return Result::failure(minCount.status()); + auto maxCount = integer(value, "maxCount", "action.targetingSpec.maxCount"); + if (!maxCount) return Result::failure(maxCount.status()); + auto minRange = number(value, "minRange", "action.targetingSpec.minRange"); + if (!minRange) return Result::failure(minRange.status()); + auto required = decodeStrings(value, "requiredTags", "action.targetingSpec.requiredTags"); + if (!required) return Result::failure(required.status()); + auto excluded = decodeStrings(value, "excludedTags", "action.targetingSpec.excludedTags"); + if (!excluded) return Result::failure(excluded.status()); + auto lineOfSight = boolean(value, "lineOfSight", "action.targetingSpec.lineOfSight"); + if (!lineOfSight) return Result::failure(lineOfSight.status()); + const std::pair spaces[] = { + {"world2d", sensing::CoordinateSpace::World2D}, {"world3d", sensing::CoordinateSpace::World3D}, + {"grid2d", sensing::CoordinateSpace::Grid2D}, {"grid3d", sensing::CoordinateSpace::Grid3D}}; + bool spaceFound = false; + for (const auto& [name, parsed] : spaces) + if (space.value() == name) { result.space = parsed; spaceFound = true; break; } + if (!spaceFound || domain.value() < 0 || + domain.value() > static_cast(sensing::TargetDomain::Neutral) || + minCount.value() < 0 || maxCount.value() < 0 || + minCount.value() > std::numeric_limits::max() || + maxCount.value() > std::numeric_limits::max()) + return invalid("targeting specification enum or count is invalid", + "action.targetingSpec"); + result.domain = static_cast(domain.value()); + result.minCount = static_cast(minCount.value()); + result.maxCount = static_cast(maxCount.value()); + result.minRange = static_cast(minRange.value()); + const auto* maxRange = field(value, "maxRange"); + if (!maxRange) return invalid("maximum range is missing", "action.targetingSpec.maxRange"); + if (maxRange->isNull()) result.maxRange = std::numeric_limits::infinity(); + else if (maxRange->isDouble() || maxRange->isInt64()) + result.maxRange = static_cast(maxRange->isDouble() ? maxRange->asDouble() : maxRange->asInt()); + else + return invalid("maximum range must be numeric or null", + "action.targetingSpec.maxRange"); + result.requiredTags = std::move(required).takeValue(); + result.excludedTags = std::move(excluded).takeValue(); + result.lineOfSight = lineOfSight.value() ? sensing::LineOfSightMode::Required + : sensing::LineOfSightMode::NotRequired; + if (const auto* zone = field(value, "zone")) { + if (!zone->isString()) return invalid("zone must be text", "action.targetingSpec.zone"); + auto id = LogicalId::parse(zone->asString()); + if (!id) return invalid("zone id is invalid", "action.targetingSpec.zone"); + result.zone = sensing::ZoneRef::fromLogicalId(*id); + } + if (const auto* area = field(value, "worldArea")) { + auto shape = integer(*area, "shape", "action.targetingSpec.worldArea.shape"); + if (!shape) return Result::failure(shape.status()); + auto first = vector3(*area, "first", "action.targetingSpec.worldArea.first"); + if (!first) return Result::failure(first.status()); + auto second = vector3(*area, "second", "action.targetingSpec.worldArea.second"); + if (!second) return Result::failure(second.status()); + auto radius = number(*area, "radius", "action.targetingSpec.worldArea.radius"); + if (!radius) return Result::failure(radius.status()); + if (shape.value() < 0 || shape.value() > 3) + return invalid("world area is invalid", "action.targetingSpec.worldArea"); + const auto makePoint = [&](const std::array& point) { + return result.space == sensing::CoordinateSpace::World2D + ? sensing::WorldPoint::world2D(static_cast(point[0]), static_cast(point[1])) + : sensing::WorldPoint::world3D(static_cast(point[0]), static_cast(point[1]), + static_cast(point[2])); + }; + auto a = makePoint(first.value()); + if (!a) return Result::failure(a.status()); + auto b = makePoint(second.value()); + if (!b) return Result::failure(b.status()); + Result decoded = + shape.value() == 0 + ? sensing::WorldArea::circle2D(a.value(), static_cast(radius.value())) + : shape.value() == 1 + ? sensing::WorldArea::box2D(a.value(), b.value()) + : shape.value() == 2 + ? sensing::WorldArea::sphere3D(a.value(), static_cast(radius.value())) + : sensing::WorldArea::box3D(a.value(), b.value()); + if (!decoded) return Result::failure(decoded.status()); + result.worldArea = std::move(decoded).takeValue(); + } + if (const auto* area = field(value, "gridArea")) { + auto shape = integer(*area, "shape", "action.targetingSpec.gridArea.shape"); + if (!shape) return Result::failure(shape.status()); + auto minimum = gridVector3(*area, "minimum", "action.targetingSpec.gridArea.minimum"); + if (!minimum) return Result::failure(minimum.status()); + auto maximum = gridVector3(*area, "maximum", "action.targetingSpec.gridArea.maximum"); + if (!maximum) return Result::failure(maximum.status()); + if (shape.value() < 0 || shape.value() > 1) + return invalid("grid area is invalid", "action.targetingSpec.gridArea"); + const auto makePoint = [&](const std::array& point) { + return result.space == sensing::CoordinateSpace::Grid2D + ? sensing::GridPoint::grid2D(point[0], point[1]) + : sensing::GridPoint::grid3D(point[0], point[1], point[2]); + }; + const auto a = makePoint(minimum.value()); + const auto b = makePoint(maximum.value()); + auto decoded = shape.value() == 0 ? sensing::GridArea::box2D(a, b) : sensing::GridArea::box3D(a, b); + if (!decoded) return Result::failure(decoded.status()); + result.gridArea = std::move(decoded).takeValue(); + } + auto valid = result.validate(); + if (!valid) return Result::failure(valid.status()); + return Result::success(std::move(result)); +} + +Result durationField(const Value& value, std::string_view nsName, std::string_view msName, + std::uint64_t version, std::string path) { + if (field(value, version == 1 ? nsName : msName)) + return invalid("duration field does not match schema version", std::move(path)); + auto raw = integer(value, version == 1 ? msName : nsName, path); + if (!raw) return Result::failure(raw.status()); + if (raw.value() < 0) return invalid("duration must be non-negative", std::move(path)); + if (version == 1 && raw.value() > std::numeric_limits::max() / 1'000'000) + return invalid("millisecond duration overflows nanoseconds", std::move(path)); + return Result::success(Duration::fromNanoseconds(version == 1 ? raw.value() * 1'000'000 : raw.value())); +} + +} // namespace + +Result encodeAbilityAsset(const AbilityDefinition& definition) { + auto valid = definition.validate(); + if (!valid) return Result::failure(valid.status()); + Value::Array costs; + if (definition.action.cost) + for (const auto& item : definition.action.cost->items()) + costs.emplace_back(Value::Object{{"resource", item.resource.value()}, {"amount", item.amount.value()}}); + Value::Array triggers; + for (const auto& trigger : definition.triggers) + triggers.emplace_back(Value::Object{ + {"gameplayTag", trigger.gameplayTag}, + {"match", trigger.match == tags::GameplayTagMatch::Exact ? "exact" : "include_descendants"}}); + Value timeline; + if (definition.action.timeline) { + auto encoded = definition.action.timeline->toValue(); + if (!encoded) return Result::failure(encoded.status()); + timeline = std::move(encoded).takeValue(); + } + Value::Object action{{"id", definition.action.id.format()}, + {"timing", Value::Object{{"windupNs", definition.action.timing.windup.nanoseconds()}, + {"activeNs", definition.action.timing.active.nanoseconds()}, + {"recoverNs", definition.action.timing.recover.nanoseconds()}}}, + {"condition", encodeCondition(definition.action.condition)}, + {"targetingMode", std::string(targetingModeName(definition.action.targetingMode))}, + {"targetingSpec", definition.action.targetingSpec + ? encodeTargetingSpec(*definition.action.targetingSpec) + : Value()}, + {"cost", definition.action.cost ? Value(std::move(costs)) : Value()}, + {"effectIds", strings(definition.action.effectIds)}, + {"activeExecutionRequired", definition.action.activeExecutionRequired}, + {"metadata", definition.action.metadata}, + {"timeline", std::move(timeline)}}; + Value::Object root{{"schema", std::string(kAbilityAssetSchemaId)}, + {"schemaVersion", static_cast(kAbilityAssetSchemaVersion)}, + {"id", definition.id.format()}, + {"action", std::move(action)}, + {"cooldownNs", definition.cooldown.nanoseconds()}, + {"instancing", std::string(instancingName(definition.instancing))}, + {"activationGroup", std::string(groupName(definition.activationGroup))}, + {"triggers", std::move(triggers)}}; + return Result::success(Value(std::move(root))); +} + +Result decodeAbilityAsset(const Value& value) { + auto rootFields = rejectUnknown(value, {"schema", "schemaVersion", "id", "action", "cooldownNs", "cooldownMs", + "instancing", "activationGroup", "triggers"}, "ability"); + if (!rootFields) return Result::failure(rootFields.status()); + auto schema = text(value, "schema", "schema"); + if (!schema) return Result::failure(schema.status()); + auto versionRaw = integer(value, "schemaVersion", "schemaVersion"); + if (!versionRaw) return Result::failure(versionRaw.status()); + if (schema.value() != kAbilityAssetSchemaId) + return invalid("unexpected ability asset schema", "schema"); + if (versionRaw.value() < 1 || versionRaw.value() > static_cast(kAbilityAssetSchemaVersion)) + return invalid("unsupported ability asset schema version", "schemaVersion"); + const auto version = static_cast(versionRaw.value()); + auto idText = text(value, "id", "id"); + if (!idText) return Result::failure(idText.status()); + auto cooldown = durationField(value, "cooldownNs", "cooldownMs", version, "cooldown"); + if (!cooldown) return Result::failure(cooldown.status()); + auto instancingText = text(value, "instancing", "instancing"); + if (!instancingText) return Result::failure(instancingText.status()); + auto groupText = text(value, "activationGroup", "activationGroup"); + if (!groupText) return Result::failure(groupText.status()); + const auto* actionValue = field(value, "action"); + const auto* triggerValue = field(value, "triggers"); + if (!actionValue || !triggerValue || !triggerValue->isArray()) + return invalid("ability asset is incomplete", "ability"); + auto id = LogicalId::parse(idText.value()); + auto instancing = parseInstancing(instancingText.value()); + if (!instancing) return Result::failure(instancing.status()); + auto group = parseGroup(groupText.value()); + if (!group) return Result::failure(group.status()); + if (!id || !actionValue->isObject()) + return invalid("ability identity or policy is invalid", "ability"); + + AbilityDefinition result; + result.id = *id; + result.cooldown = cooldown.value(); + result.instancing = instancing.value(); + result.activationGroup = group.value(); + auto actionIdText = text(*actionValue, "id", "action.id"); + if (!actionIdText) return Result::failure(actionIdText.status()); + const auto* timing = field(*actionValue, "timing"); + auto modeText = text(*actionValue, "targetingMode", "action.targetingMode"); + if (!modeText) return Result::failure(modeText.status()); + auto activeRequired = boolean(*actionValue, "activeExecutionRequired", "action.activeExecutionRequired"); + if (!activeRequired) return Result::failure(activeRequired.status()); + auto effects = decodeStrings(*actionValue, "effectIds", "action.effectIds"); + if (!effects) return Result::failure(effects.status()); + const auto* metadata = field(*actionValue, "metadata"); + const auto* condition = field(*actionValue, "condition"); + if (!timing || !timing->isObject() || !metadata || !metadata->isObject() || !condition) + return invalid("action definition is incomplete", "action"); + auto actionId = LogicalId::parse(actionIdText.value()); + auto windup = durationField(*timing, "windupNs", "windupMs", version, "action.timing.windup"); + if (!windup) return Result::failure(windup.status()); + auto active = durationField(*timing, "activeNs", "activeMs", version, "action.timing.active"); + if (!active) return Result::failure(active.status()); + auto recover = durationField(*timing, "recoverNs", "recoverMs", version, "action.timing.recover"); + if (!recover) return Result::failure(recover.status()); + auto mode = parseTargetingMode(modeText.value()); + if (!mode) return Result::failure(mode.status()); + auto decodedCondition = decodeCondition(*condition, "action.condition"); + if (!decodedCondition) return Result::failure(decodedCondition.status()); + if (!actionId) return invalid("action identity is invalid", "action.id"); + result.action.id = *actionId; + result.action.timing = {windup.value(), active.value(), recover.value()}; + result.action.condition = std::move(decodedCondition).takeValue(); + result.action.targetingMode = mode.value(); + result.action.activeExecutionRequired = activeRequired.value(); + result.action.effectIds = std::move(effects).takeValue(); + result.action.metadata = *metadata->getIf(); + if (const auto* spec = field(*actionValue, "targetingSpec"); spec && !spec->isNull()) { + auto decoded = decodeTargetingSpec(*spec); + if (!decoded) return Result::failure(decoded.status()); + result.action.targetingSpec = std::move(decoded).takeValue(); + } + if (const auto* cost = field(*actionValue, "cost"); cost && !cost->isNull()) { + if (!cost->isArray()) return invalid("action cost must be an array or null", "action.cost"); + std::vector items; + const auto& array = *cost->getIf(); + for (std::size_t index = 0; index < array.size(); ++index) { + auto resourceName = text(array[index], "resource", "action.cost[" + std::to_string(index) + "].resource"); + if (!resourceName) return Result::failure(resourceName.status()); + auto amount = integer(array[index], "amount", "action.cost[" + std::to_string(index) + "].amount"); + if (!amount) return Result::failure(amount.status()); + auto item = resource::ResourceCost::create(resourceName.value(), amount.value()); + if (!item) return Result::failure(item.status()); + items.push_back(std::move(item).takeValue()); + } + auto costSpec = resource::CostSpec::create(std::move(items)); + if (!costSpec) return Result::failure(costSpec.status()); + result.action.cost = std::move(costSpec).takeValue(); + } + if (const auto* timeline = field(*actionValue, "timeline"); timeline && !timeline->isNull()) { + auto decoded = ActionTimeline::fromValue(*timeline); + if (!decoded) return Result::failure(decoded.status()); + result.action.timeline = std::move(decoded).takeValue(); + } + for (std::size_t index = 0; index < triggerValue->getIf()->size(); ++index) { + const auto& item = triggerValue->getIf()->at(index); + auto tag = text(item, "gameplayTag", "triggers[" + std::to_string(index) + "].gameplayTag"); + if (!tag) return Result::failure(tag.status()); + auto match = text(item, "match", "triggers[" + std::to_string(index) + "].match"); + if (!match) return Result::failure(match.status()); + if (match.value() != "exact" && match.value() != "include_descendants") + return invalid("ability trigger is invalid", "triggers[" + std::to_string(index) + "]"); + result.triggers.push_back({tag.value(), match.value() == "exact" ? tags::GameplayTagMatch::Exact + : tags::GameplayTagMatch::IncludeDescendants}); + } + auto valid = result.validate(); + if (!valid) return Result::failure(valid.status()); + return Result::success(std::move(result)); +} + +} // namespace eve::action diff --git a/src/modules/action/AbilityAsset.h b/src/modules/action/AbilityAsset.h new file mode 100644 index 00000000..100dd3c4 --- /dev/null +++ b/src/modules/action/AbilityAsset.h @@ -0,0 +1,29 @@ +#pragma once + +/** @file AbilityAsset.h @brief Versioned persistent codec for ability definitions. */ + +#include "action/AbilitySystem.h" +#include "common/SchemaVersion.h" + +namespace eve::action { + +inline constexpr std::string_view kAbilityAssetSchemaId = "eve.action.ability"; +inline constexpr std::uint64_t kAbilityAssetSchemaVersion = 2; + +/** + * @brief Encode one validated ability definition as canonical schema version 2 data. + * @param definition Owning definition borrowed only for this synchronous call. + * @return Independently owning Value, or a structured validation/encoding failure. + * @remarks Owner-thread-only and reentrant; no callbacks, registries, files, or clocks are used. + */ +[[nodiscard]] Result encodeAbilityAsset(const AbilityDefinition& definition); + +/** + * @brief Decode schema version 2 or migrate version 1 millisecond data to the current definition. + * @param value Untrusted owning value tree borrowed only for this synchronous call. + * @return Fully validated owning definition, or a structured parse, migration, or validation failure. + * @remarks Unknown top-level fields and unknown/future versions are rejected. Failure publishes no runtime state. + */ +[[nodiscard]] Result decodeAbilityAsset(const Value& value); + +} // namespace eve::action diff --git a/src/modules/action/AbilitySystem.cpp b/src/modules/action/AbilitySystem.cpp index 9524b631..a2968d8f 100644 --- a/src/modules/action/AbilitySystem.cpp +++ b/src/modules/action/AbilitySystem.cpp @@ -54,6 +54,26 @@ Result AbilityRuntime::registerDefinition(AbilityDefinition definition) { return Result::success(Status::success(StatusCode::Applied)); } +Result AbilityRuntime::replaceDefinition(AbilityDefinition definition) { + auto valid = definition.validate(); + if (!valid) return valid; + const std::string key = definition.id.format(); + const auto found = definitions_.find(key); + if (found == definitions_.end()) + return failure(DiagnosticCode::NotFound, "Ability definition is not registered", key); + if (found->second.instancing != definition.instancing) { + const bool hasGrant = std::any_of(grants_.begin(), grants_.end(), [&](const auto& item) { + return item.second.state.definitionId == definition.id; + }); + if (hasGrant) + return failure(DiagnosticCode::Conflict, + "Granted ability cannot change its instancing policy during hot reload", + "instancing"); + } + found->second = std::move(definition); + return Result::success(Status::success(StatusCode::Applied)); +} + Result AbilityRuntime::grant(std::string ownerId, const LogicalId& definitionId) { if (ownerId.empty()) return failure(DiagnosticCode::InvalidArgument, "Ability owner is empty", "ownerId"); diff --git a/src/modules/action/AbilitySystem.h b/src/modules/action/AbilitySystem.h index 4b4e4fef..66c568c0 100644 --- a/src/modules/action/AbilitySystem.h +++ b/src/modules/action/AbilitySystem.h @@ -80,6 +80,14 @@ public: /** @brief Register one canonical definition; duplicate ids are rejected. */ [[nodiscard]] Result registerDefinition(AbilityDefinition definition); + /** + * @brief Transactionally replace one registered definition for future activations. + * + * Existing grants and cooldowns remain valid. Active ActionExecution objects + * retain the owning definition snapshot captured when they were submitted. + * A definition with grants cannot change its instancing policy. + */ + [[nodiscard]] Result replaceDefinition(AbilityDefinition definition); /** @brief Grant a registered definition to a non-empty owner identity. */ [[nodiscard]] Result grant(std::string ownerId, const LogicalId& definitionId); /** @brief Revoke an inactive grant. Active grants must be synchronized/cancelled first. */ diff --git a/src/modules/action/Action.cpp b/src/modules/action/Action.cpp index 7b07a858..dbde6919 100644 --- a/src/modules/action/Action.cpp +++ b/src/modules/action/Action.cpp @@ -505,6 +505,7 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic } std::vector timelineEvents; + std::vector activeBlocks; if (execution->definition_.timeline) { auto sampled = execution->definition_.timeline->sample(timelinePrevious, execution->totalElapsed_, !execution->timelineStarted_); @@ -514,6 +515,12 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic } timelineEvents = std::move(sampled).takeValue(); execution->timelineStarted_ = true; + auto active = execution->definition_.timeline->activeBlocks(execution->totalElapsed_); + if (!active) { + failExecution(*execution, active.status(), tick, &transitions); + return failureFrom(execution->status()); + } + activeBlocks = std::move(active).takeValue(); } if (execution->phase_ == ActionPhase::Completed) { ActionAdvance result{id, @@ -521,7 +528,8 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic std::move(transitions), execution->phaseElapsed_, execution->totalElapsed_, - std::move(timelineEvents)}; + std::move(timelineEvents), + std::move(activeBlocks)}; return Result::success(std::move(result), Status::success(StatusCode::Applied)); } ActionAdvance result{id, @@ -529,7 +537,8 @@ Result ActionRuntime::advance(ActionExecutionId id, SimulationTic std::move(transitions), execution->phaseElapsed_, execution->totalElapsed_, - std::move(timelineEvents)}; + std::move(timelineEvents), + std::move(activeBlocks)}; return Result::success(std::move(result), pendingStatus()); } diff --git a/src/modules/action/Action.h b/src/modules/action/Action.h index 05c18c98..13ea2825 100644 --- a/src/modules/action/Action.h +++ b/src/modules/action/Action.h @@ -183,6 +183,8 @@ struct ActionAdvance { Duration totalElapsed = Duration::zero(); /** @brief Owning timeline boundaries crossed by this advance. */ std::vector timelineEvents; + /** @brief Owning samples of timeline states active after this advance. */ + std::vector activeBlocks; }; /** diff --git a/src/modules/action/ActionAudioBlock.cpp b/src/modules/action/ActionAudioBlock.cpp new file mode 100644 index 00000000..3727ae22 --- /dev/null +++ b/src/modules/action/ActionAudioBlock.cpp @@ -0,0 +1,115 @@ +#include "action/ActionAudioBlock.h" + +#include +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result numeric(const Value::Object& payload, std::string_view key, double fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(fallback); + double value = 0.0; + if (const auto* integer = found->second.getIf()) + value = static_cast(*integer); + else if (const auto* decimal = found->second.getIf()) + value = *decimal; + else + return invalid("audio field must be numeric", std::string(key)); + if (!std::isfinite(value)) return invalid("audio field must be finite", std::string(key)); + return Result::success(value); +} + +} // namespace + +Result ActionAudioBinding::fromPayload(const Value::Object& payload, ActionAudioShape shape) { + ActionAudioBinding candidate; + const auto uri = payload.find("uri"); + if (uri == payload.end() || !uri->second.getIf() || uri->second.getIf()->empty()) + return invalid("audio URI must be non-empty text", "uri"); + candidate.uri = *uri->second.getIf(); + + if (const auto randomUris = payload.find("randomUris"); randomUris != payload.end()) { + const auto* values = randomUris->second.getIf(); + if (!values) return invalid("random audio URIs must be an array", "randomUris"); + std::set unique; + for (std::size_t index = 0; index < values->size(); ++index) { + const auto* value = (*values)[index].getIf(); + const auto path = "randomUris[" + std::to_string(index) + "]"; + if (!value || value->empty()) + return invalid("random audio URI must be non-empty text", path); + if (!unique.insert(*value).second) + return invalid("random audio URI must be unique", path); + candidate.randomUris.push_back(*value); + } + } + + auto spatial = ActionSpatialBinding::fromPayload(payload); + if (!spatial) return Result::failure(spatial.status()); + candidate.spatial = std::move(spatial).takeValue(); + + auto volume = numeric(payload, "volume", 1.0); + if (!volume) return Result::failure(volume.status()); + if (volume.value() < 0.0) return invalid("audio volume must be non-negative", "volume"); + candidate.volume = volume.value(); + + auto pitch = numeric(payload, "pitch", 1.0); + if (!pitch) return Result::failure(pitch.status()); + if (pitch.value() <= 0.0) return invalid("audio pitch must be positive", "pitch"); + candidate.pitch = pitch.value(); + + auto randomPitchOffset = numeric(payload, "randomPitchOffset", 0.0); + if (!randomPitchOffset) return Result::failure(randomPitchOffset.status()); + if (randomPitchOffset.value() < 0.0 || randomPitchOffset.value() > 0.5) + return invalid("random pitch offset must be between zero and 0.5", + "randomPitchOffset"); + candidate.randomPitchOffset = randomPitchOffset.value(); + + auto spatialBlend = numeric(payload, "spatialBlend", 1.0); + if (!spatialBlend) return Result::failure(spatialBlend.status()); + if (spatialBlend.value() < 0.0 || spatialBlend.value() > 1.0) + return invalid("audio spatial blend must be between zero and one", "spatialBlend"); + candidate.spatialBlend = spatialBlend.value(); + + auto minDistance = numeric(payload, "minDistance", 1.0); + if (!minDistance) return Result::failure(minDistance.status()); + if (minDistance.value() <= 0.0) + return invalid("audio minimum distance must be positive", "minDistance"); + candidate.minDistance = minDistance.value(); + + auto maxDistance = numeric(payload, "maxDistance", 35.0); + if (!maxDistance) return Result::failure(maxDistance.status()); + if (maxDistance.value() < candidate.minDistance) + return invalid("audio maximum distance must not be below minimum distance", + "maxDistance"); + candidate.maxDistance = maxDistance.value(); + + if (const auto looping = payload.find("looping"); looping != payload.end()) { + const auto* enabled = looping->second.getIf(); + if (!enabled) return invalid("audio looping must be boolean", "looping"); + candidate.looping = *enabled; + } + if (shape == ActionAudioShape::Instant && candidate.looping) + return invalid("instant audio cannot loop; use audio-state", "looping"); + if (const auto fadeOut = payload.find("fadeOutOnExit"); fadeOut != payload.end()) { + const auto* enabled = fadeOut->second.getIf(); + if (!enabled) return invalid("audio fade-out flag must be boolean", "fadeOutOnExit"); + candidate.fadeOutOnExit = *enabled; + } + auto fadeOutDuration = numeric(payload, "fadeOutDuration", 0.1); + if (!fadeOutDuration) return Result::failure(fadeOutDuration.status()); + if (fadeOutDuration.value() <= 0.0 || fadeOutDuration.value() > 10.0) + return invalid("audio fade-out duration must be in (0, 10] seconds", + "fadeOutDuration"); + candidate.fadeOutDuration = fadeOutDuration.value(); + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionAudioBlock.h b/src/modules/action/ActionAudioBlock.h new file mode 100644 index 00000000..eb3c769c --- /dev/null +++ b/src/modules/action/ActionAudioBlock.h @@ -0,0 +1,46 @@ +#pragma once + +/** @file ActionAudioBlock.h @brief Typed audio action-block payload contract. */ + +#include "action/ActionSpatialBlock.h" + +#include +#include +#include + +namespace eve::action { + +/** @brief Authored audio block shape used to validate looping semantics. */ +enum class ActionAudioShape : std::uint8_t { Instant, State }; + +/** @brief Owning validated settings shared by Audio runtime and editor preview. */ +struct ActionAudioBinding { + std::string uri; + std::vector randomUris; + ActionSpatialBinding spatial; + double volume = 1.0; + double pitch = 1.0; + double randomPitchOffset = 0.0; + double spatialBlend = 1.0; + double minDistance = 1.0; + double maxDistance = 35.0; + bool looping = false; + bool fadeOutOnExit = true; + double fadeOutDuration = 0.1; + + auto operator<=>(const ActionAudioBinding&) const = default; + + /** + * @brief Decode an audio payload transactionally. + * @param payload Owning dynamic payload borrowed only for this call. + * @param shape Instant or state contract selected by the descriptor. + * @return Validated owning settings or a field-path diagnostic. + * @remarks Random URI entries must be non-empty and unique. Volume, pitch, + * pitch variation, spatial blend, and attenuation distances use bounded, + * finite ranges. Instant blocks cannot loop. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload, + ActionAudioShape shape); +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionAudioWaveform.h b/src/modules/action/ActionAudioWaveform.h new file mode 100644 index 00000000..3e83a037 --- /dev/null +++ b/src/modules/action/ActionAudioWaveform.h @@ -0,0 +1,58 @@ +#pragma once + +/** @file ActionAudioWaveform.h @brief Optional audio waveform projection for action editors. */ + +#include "action/ActionAudioBlock.h" +#include "common/Time.h" + +#include +#include + +namespace eve::action { + +/** @brief One normalized min/max envelope bucket in timeline display order. */ +struct ActionAudioWaveformBucket { + float minimum = 0.0f; + float maximum = 0.0f; +}; + +/** @brief Owning request for a bounded audio-block waveform projection. */ +struct ActionAudioWaveformRequest { + /** @brief Validated owning audio settings; only URI, pitch and looping affect the projection. */ + ActionAudioBinding binding; + /** @brief Authored timeline span represented by the block. */ + Duration blockDuration; + /** @brief Requested bounded horizontal resolution. */ + std::size_t bucketCount = 0; +}; + +/** @brief Owning waveform projection returned by an optional audio provider. */ +struct ActionAudioWaveform { + /** @brief Decoded source duration used for diagnostics and host labels. */ + double clipDurationSeconds = 0.0; + /** @brief Min/max buckets in increasing block-local time. */ + std::vector buckets; +}; + +/** + * @brief Optional synchronous audio waveform service consumed by the action editor. + * + * The provider owns decoded resources and returns an independent bounded value. + * Calls are owner-thread-only and must not retain the borrowed request. Module + * shutdown must revoke the capability before destroying the implementation. + */ +class IActionAudioWaveformProvider { +public: + static constexpr const char* capabilityName = "IActionAudioWaveformProvider"; + virtual ~IActionAudioWaveformProvider() = default; + + /** + * @brief Decode and project one validated audio block into display buckets. + * @param request Borrowed request valid only for this synchronous call. + * @return Owning waveform, or a structured resource/format diagnostic. + */ + [[nodiscard]] virtual Result waveform( + const ActionAudioWaveformRequest& request) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionBlockRuntime.cpp b/src/modules/action/ActionBlockRuntime.cpp new file mode 100644 index 00000000..8ef4c92b --- /dev/null +++ b/src/modules/action/ActionBlockRuntime.cpp @@ -0,0 +1,62 @@ +#include "action/ActionBlockRuntime.h" + +#include + +namespace eve::action { +namespace { + +Result invalidContext() { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, + "Action block context does not match advance", "executionId")); +} + +} // namespace + +Result ActionBlockRuntime::apply(const ActionAdvance& advance, ActionNotifyContext context) { + if (context.executionId != advance.id) return invalidContext(); + context.time = advance.totalElapsed; + for (const auto& event : advance.timelineEvents) { + auto dispatched = registry_.dispatch(event, context); + if (!dispatched) return dispatched; + } + for (const auto& block : advance.activeBlocks) { + auto updated = registry_.dispatchUpdate(block, context); + if (!updated) return updated; + } + auto maintained = registry_.advanceHandlers(context); + if (!maintained) return maintained; + if (advance.activeBlocks.empty()) + active_.erase(advance.id); + else + active_[advance.id] = advance.activeBlocks; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result ActionBlockRuntime::interrupt(ActionNotifyContext context) { + const auto found = active_.find(context.executionId); + if (found == active_.end()) return Result::success(Status::success(StatusCode::NoOp)); + context.interrupted = true; + auto& blocks = found->second; + while (!blocks.empty()) { + const auto& block = blocks.front(); + ActionTimelineEvent event{ActionTimelineEventKind::StateExit, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto dispatched = registry_.dispatch(event, context); + if (!dispatched) return dispatched; + blocks.erase(blocks.begin()); + } + active_.erase(found); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result ActionBlockRuntime::sample(const std::vector& blocks, ActionNotifyContext context) const { + context.preview = true; + context.scrubbing = true; + for (const auto& block : blocks) { + auto sampled = registry_.dispatchSample(block, context); + if (!sampled) return sampled; + } + return Result::success(Status::success(blocks.empty() ? StatusCode::NoOp : StatusCode::Applied)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionBlockRuntime.h b/src/modules/action/ActionBlockRuntime.h new file mode 100644 index 00000000..53288b88 --- /dev/null +++ b/src/modules/action/ActionBlockRuntime.h @@ -0,0 +1,63 @@ +#pragma once + +/** @file ActionBlockRuntime.h @brief Deterministic notify-state lifecycle routing. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include + +namespace eve::action { + +/** + * @brief Owner-thread lifecycle router for timeline notifies and active blocks. + * + * ActionRuntime remains the timeline authority. This adapter retains only the + * last owning block projection per execution so interruption can emit paired + * exits. The borrowed registry must outlive this object. Handler callbacks are + * synchronous, run without locks, and may reenter unrelated systems but must + * not reenter this instance. + */ +class ActionBlockRuntime { +public: + /** + * @brief Bind the canonical registry used for all synchronous callbacks. + * @param registry Borrowed owner-thread registry. + */ + explicit ActionBlockRuntime(ActionNotifyRegistry& registry) noexcept : registry_(registry) {} + + ActionBlockRuntime(const ActionBlockRuntime&) = delete; + ActionBlockRuntime& operator=(const ActionBlockRuntime&) = delete; + + /** + * @brief Deliver boundaries and active updates from one authoritative advance. + * @param advance Owning result produced by ActionRuntime. + * @param context Owning execution/target context; its id must match advance. + * @return Applied after all handlers succeed, otherwise the first handler failure. + */ + [[nodiscard]] Result apply(const ActionAdvance& advance, ActionNotifyContext context); + + /** + * @brief Emit paired exits for all blocks retained for one interrupted execution. + * @param context Owning context identifying the execution to interrupt. + * @return Applied/NoOp or the first exit failure; successfully exited blocks are removed. + */ + [[nodiscard]] Result interrupt(ActionNotifyContext context); + + /** + * @brief Evaluate active blocks for editor scrubbing without retaining lifecycle state. + * @param blocks Owning block samples from ActionTimeline::activeBlocks. + * @param context Preview context; preview and scrubbing are forced true. + */ + [[nodiscard]] Result sample(const std::vector& blocks, + ActionNotifyContext context) const; + + /** @brief Number of execution projections retained for interruption cleanup. */ + [[nodiscard]] std::size_t executionCount() const noexcept { return active_.size(); } + +private: + ActionNotifyRegistry& registry_; + std::map> active_; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionCameraBlock.cpp b/src/modules/action/ActionCameraBlock.cpp new file mode 100644 index 00000000..40e92277 --- /dev/null +++ b/src/modules/action/ActionCameraBlock.cpp @@ -0,0 +1,80 @@ +#include "action/ActionCameraBlock.h" + +#include +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result optionalNumber(const Value::Object& payload, std::string_view field, double fallback) { + const auto found = payload.find(std::string(field)); + if (found == payload.end()) return Result::success(fallback); + double value = 0.0; + if (const auto* integer = found->second.getIf()) + value = static_cast(*integer); + else if (const auto* decimal = found->second.getIf()) + value = *decimal; + else + return invalid("camera cue field must be numeric", std::string(field)); + if (!std::isfinite(value)) return invalid("camera cue field must be finite", std::string(field)); + return Result::success(value); +} + +} // namespace + +Result ActionCameraCueBinding::fromPayload(const Value::Object& payload) { + ActionCameraCueBinding candidate; + const auto foundCue = payload.find("cue"); + const auto* cue = foundCue == payload.end() ? nullptr : foundCue->second.getIf(); + if (!cue) return invalid("camera cue must be text", "cue"); + auto parsedCue = LogicalId::parse(*cue); + if (!parsedCue) return invalid("camera cue must be a LogicalId", "cue"); + candidate.cue = std::move(*parsedCue); + + auto position = optionalNumber(payload, "positionAmplitude", candidate.positionAmplitude); + if (!position) return Result::failure(position.status()); + auto rotation = optionalNumber(payload, "rotationAmplitude", candidate.rotationAmplitude); + if (!rotation) return Result::failure(rotation.status()); + auto fov = optionalNumber(payload, "fovAmplitude", candidate.fovAmplitude); + if (!fov) return Result::failure(fov.status()); + auto duration = optionalNumber(payload, "durationSeconds", candidate.duration.seconds()); + if (!duration) return Result::failure(duration.status()); + if (position.value() < 0.0) + return invalid("camera position amplitude must be non-negative", "positionAmplitude"); + if (rotation.value() < 0.0) + return invalid("camera rotation amplitude must be non-negative", "rotationAmplitude"); + const double maximumFloat = static_cast(std::numeric_limits::max()); + if (position.value() > maximumFloat) + return invalid("camera position amplitude exceeds runtime range", "positionAmplitude"); + if (rotation.value() > maximumFloat) + return invalid("camera rotation amplitude exceeds runtime range", "rotationAmplitude"); + if (std::abs(fov.value()) > maximumFloat) + return invalid("camera FOV amplitude exceeds runtime range", "fovAmplitude"); + if (duration.value() <= 0.0) + return invalid("camera cue duration must be positive", "durationSeconds"); + if (duration.value() > maximumFloat) + return invalid("camera cue duration exceeds runtime range", "durationSeconds"); + auto parsedDuration = Duration::fromSeconds(duration.value()); + if (!parsedDuration) return Result::failure(parsedDuration.status()); + candidate.positionAmplitude = position.value(); + candidate.rotationAmplitude = rotation.value(); + candidate.fovAmplitude = fov.value(); + candidate.duration = std::move(parsedDuration).takeValue(); + + if (const auto foundSeed = payload.find("seed"); foundSeed != payload.end()) { + const auto* seed = foundSeed->second.getIf(); + if (!seed || *seed < 0 || static_cast(*seed) > std::numeric_limits::max()) + return invalid("camera cue seed must be an unsigned 32-bit integer", "seed"); + candidate.seed = static_cast(*seed); + } + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionCameraBlock.h b/src/modules/action/ActionCameraBlock.h new file mode 100644 index 00000000..501c356f --- /dev/null +++ b/src/modules/action/ActionCameraBlock.h @@ -0,0 +1,54 @@ +#pragma once + +/** @file ActionCameraBlock.h @brief Typed camera-cue action block contract. */ + +#include "action/ActionNotifyRegistry.h" + +#include + +namespace eve::action { + +/** @brief Owning validated camera impulse shared by action runtime and camera targets. */ +struct ActionCameraCueBinding { + /** @brief Stable project-defined cue identity used for target selection. */ + LogicalId cue; + /** @brief Positional shake amplitude in world units. */ + double positionAmplitude = 0.15; + /** @brief Rotational shake amplitude in degrees. */ + double rotationAmplitude = 1.5; + /** @brief Additive vertical field-of-view impulse in degrees. */ + double fovAmplitude = 0.0; + /** @brief Positive duration of the damped impulse. */ + Duration duration = Duration::fromNanoseconds(250000000); + /** @brief Stable deterministic noise seed. */ + std::uint32_t seed = 0; + + auto operator<=>(const ActionCameraCueBinding&) const = default; + + /** + * @brief Decode a camera cue transactionally. + * @param payload Owning dynamic payload borrowed only for this call. + * @return Validated owning cue or a field-path diagnostic. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** + * @brief Optional open target for instantaneous action camera cues. + * + * Implementations own camera state. Calls are synchronous and owner-thread-only; + * implementations must not retain the borrowed binding or context. + */ +class IActionCameraCueSink { +public: + static constexpr const char* capabilityName = "eve.action.camera-cue-sink"; + virtual ~IActionCameraCueSink() = default; + + /** @brief Whether this sink owns the supplied stable cue identity. */ + [[nodiscard]] virtual bool supports(const LogicalId& cue) const noexcept = 0; + /** @brief Trigger one already validated camera cue. */ + [[nodiscard]] virtual Result trigger(const ActionCameraCueBinding& binding, + const ActionNotifyContext& context) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionDamageBlock.cpp b/src/modules/action/ActionDamageBlock.cpp new file mode 100644 index 00000000..c032e0b9 --- /dev/null +++ b/src/modules/action/ActionDamageBlock.cpp @@ -0,0 +1,79 @@ +#include "action/ActionDamageBlock.h" + +#include "tags/GameplayTag.h" + +#include +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result number(const Value& value, std::string path) { + double result = 0.0; + if (const auto* integer = value.getIf()) + result = static_cast(*integer); + else if (const auto* decimal = value.getIf()) + result = *decimal; + else + return invalid("damage field must be numeric", std::move(path)); + if (!std::isfinite(result)) return invalid("damage field must be finite", std::move(path)); + return Result::success(result); +} + +Result optionalNumber(const Value::Object& payload, const char* field, double fallback) { + const auto found = payload.find(field); + return found == payload.end() ? Result::success(fallback) : number(found->second, field); +} + +} // namespace + +Result ActionDamageBinding::fromPayload(const Value::Object& payload) { + ActionDamageBinding candidate; + const auto foundType = payload.find("damageType"); + const auto* damageType = foundType == payload.end() ? nullptr : foundType->second.getIf(); + if (!damageType || !tags::isValidGameplayTagName(*damageType)) + return invalid("damage type must be a gameplay tag", "damageType"); + candidate.damageType = *damageType; + + const auto foundAmount = payload.find("amount"); + if (foundAmount == payload.end()) return invalid("damage amount is required", "amount"); + auto amount = number(foundAmount->second, "amount"); + if (!amount) return Result::failure(amount.status()); + auto poise = optionalNumber(payload, "poiseAmount", 0.0); + if (!poise) return Result::failure(poise.status()); + if (amount.value() < 0.0) return invalid("damage amount must be non-negative", "amount"); + if (poise.value() < 0.0) + return invalid("poise damage must be non-negative", "poiseAmount"); + candidate.amount = amount.value(); + candidate.poiseAmount = poise.value(); + + if (const auto foundTarget = payload.find("targetIndex"); foundTarget != payload.end()) { + const auto* target = foundTarget->second.getIf(); + if (!target || *target < 0 || static_cast(*target) > std::numeric_limits::max()) + return invalid("damage target index must be an unsigned 32-bit integer", "targetIndex"); + candidate.targetIndex = static_cast(*target); + } + + if (const auto foundKnockback = payload.find("knockback"); foundKnockback != payload.end()) { + const auto* values = foundKnockback->second.getIf(); + if (!values || values->size() != 3) + return invalid("damage knockback must contain three numbers", "knockback"); + auto x = number((*values)[0], "knockback[0]"); + if (!x) return Result::failure(x.status()); + auto y = number((*values)[1], "knockback[1]"); + if (!y) return Result::failure(y.status()); + auto z = number((*values)[2], "knockback[2]"); + if (!z) return Result::failure(z.status()); + candidate.knockback = {x.value(), y.value(), z.value()}; + } + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionDamageBlock.h b/src/modules/action/ActionDamageBlock.h new file mode 100644 index 00000000..283865f4 --- /dev/null +++ b/src/modules/action/ActionDamageBlock.h @@ -0,0 +1,58 @@ +#pragma once + +/** @file ActionDamageBlock.h @brief Typed instantaneous combat-damage action contract. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include + +namespace eve::action { + +/** @brief Renderer- and combat-implementation-neutral knockback impulse. */ +struct ActionDamageImpulse { + double x = 0.0; + double y = 0.0; + double z = 0.0; + + auto operator<=>(const ActionDamageImpulse&) const = default; +}; + +/** @brief Owning validated damage request authored on an action timeline. */ +struct ActionDamageBinding { + /** @brief Gameplay-tag damage classification. */ + std::string damageType; + /** @brief Non-negative health damage. */ + double amount = 0.0; + /** @brief Non-negative poise damage. */ + double poiseAmount = 0.0; + /** @brief Target index into ActionNotifyContext::targets. */ + std::size_t targetIndex = 0; + /** @brief Optional world-space knockback impulse. */ + ActionDamageImpulse knockback; + + auto operator<=>(const ActionDamageBinding&) const = default; + + /** @brief Decode and validate a damage payload without mutating combat state. */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** + * @brief Optional target-domain adapter for timeline damage. + * + * Implementations resolve the generation-qualified ECS target synchronously + * and own all combat-state mutation. No borrowed binding/context data may be retained. + */ +class IActionDamageSink { +public: + static constexpr const char* capabilityName = "eve.action.damage-sink"; + virtual ~IActionDamageSink() = default; + + /** @brief Whether this sink can resolve the supplied target handle now. */ + [[nodiscard]] virtual bool supports(ecs::EntityHandle target) const = 0; + /** @brief Atomically apply one validated damage request. */ + [[nodiscard]] virtual Result apply(const ActionDamageBinding& binding, + const ActionNotifyContext& context) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionGameplayEventBlock.cpp b/src/modules/action/ActionGameplayEventBlock.cpp new file mode 100644 index 00000000..aadcb21a --- /dev/null +++ b/src/modules/action/ActionGameplayEventBlock.cpp @@ -0,0 +1,37 @@ +#include "action/ActionGameplayEventBlock.h" + +#include "tags/GameplayTag.h" + +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +} // namespace + +Result ActionGameplayEventBinding::fromPayload(const Value::Object& payload) { + ActionGameplayEventBinding candidate; + const auto foundTag = payload.find("tag"); + const auto* tag = foundTag == payload.end() ? nullptr : foundTag->second.getIf(); + if (!tag || !tags::isValidGameplayTagName(*tag)) + return invalid("gameplay event tag must be canonical", "tag"); + candidate.tag = *tag; + + if (const auto foundTarget = payload.find("targetIndex"); foundTarget != payload.end()) { + const auto* target = foundTarget->second.getIf(); + if (!target || *target < 0 || static_cast(*target) > std::numeric_limits::max()) + return invalid( + "gameplay event target index must be an unsigned 32-bit integer", "targetIndex"); + candidate.targetIndex = static_cast(*target); + } + if (const auto foundData = payload.find("data"); foundData != payload.end()) candidate.data = foundData->second; + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionGameplayEventBlock.h b/src/modules/action/ActionGameplayEventBlock.h new file mode 100644 index 00000000..b47e1fe3 --- /dev/null +++ b/src/modules/action/ActionGameplayEventBlock.h @@ -0,0 +1,41 @@ +#pragma once + +/** @file ActionGameplayEventBlock.h @brief Typed instantaneous gameplay-event action contract. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include + +namespace eve::action { + +/** @brief Owning validated event request authored on an action timeline. */ +struct ActionGameplayEventBinding { + /** @brief Canonical gameplay-tag event topic. */ + std::string tag; + /** @brief Optional target index; absence routes the event to the source subject. */ + std::optional targetIndex; + /** @brief Owning schema-neutral domain payload retained by the target event adapter. */ + Value data = Value(Value::Object{}); + + /** @brief Decode and validate an event payload without emitting it. */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** + * @brief Optional target-domain adapter for instantaneous timeline gameplay events. + * + * Implementations own event ordering, persistence, and subject resolution. Calls + * are synchronous on the ActionRuntime owner thread and may not retain arguments. + */ +class IActionGameplayEventSink { +public: + static constexpr const char* capabilityName = "eve.action.gameplay-event-sink"; + virtual ~IActionGameplayEventSink() = default; + + /** @brief Emit one validated event or return a structured domain failure. */ + [[nodiscard]] virtual Result emit(const ActionGameplayEventBinding& binding, + const ActionNotifyContext& context) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionNotifyRegistry.cpp b/src/modules/action/ActionNotifyRegistry.cpp index 0d3fa7d1..06997a64 100644 --- a/src/modules/action/ActionNotifyRegistry.cpp +++ b/src/modules/action/ActionNotifyRegistry.cpp @@ -1,5 +1,19 @@ #include "action/ActionNotifyRegistry.h" +#include "action/ActionAudioBlock.h" +#include "action/ActionCameraBlock.h" +#include "action/ActionDamageBlock.h" +#include "action/ActionGameplayEventBlock.h" +#include "action/ActionParameterCurve.h" +#include "action/ActionPrefabBlock.h" +#include "action/ActionStateWindowBlock.h" +#include "action/ActionVfxBlock.h" + +#include "common/Capability.h" + +#include +#include +#include #include namespace eve::action { @@ -16,6 +30,156 @@ Result failure(DiagnosticCode code, std::string message, std::string path bool validType(std::string_view type) { return LogicalId::parse(type).has_value(); } +class ActionCameraCueHandler final : public IActionNotifyHandler { +public: + Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) override { + if (event.kind != ActionTimelineEventKind::Notify) + return failure(DiagnosticCode::InvalidArgument, "camera cue must be instantaneous", "event.kind"); + auto binding = ActionCameraCueBinding::fromPayload(event.payload); + if (!binding) return Result::failure(binding.status()); + std::optional> result; + cap::forEachUntil([&](IActionCameraCueSink* sink) { + if (!sink->supports(binding.value().cue)) return false; + result.emplace(sink->trigger(binding.value(), context)); + return true; + }); + if (result) return std::move(*result); + return failure(DiagnosticCode::NotFound, "no camera sink accepts the action cue", binding.value().cue.format()); + } +}; + +class ActionDamageHandler final : public IActionNotifyHandler { +public: + Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) override { + if (event.kind != ActionTimelineEventKind::Notify) + return failure(DiagnosticCode::InvalidArgument, "damage block must be instantaneous", "event.kind"); + auto binding = ActionDamageBinding::fromPayload(event.payload); + if (!binding) return Result::failure(binding.status()); + if (binding.value().targetIndex >= context.targets.size()) + return failure(DiagnosticCode::NotFound, "damage target index is unavailable", "targetIndex"); + std::optional> result; + cap::forEachUntil([&](IActionDamageSink* sink) { + if (!sink->supports(context.targets[binding.value().targetIndex])) return false; + result.emplace(sink->apply(binding.value(), context)); + return true; + }); + if (result) return std::move(*result); + return failure(DiagnosticCode::NotFound, "no damage sink accepts the action target", "target"); + } +}; + +class ActionGameplayEventHandler final : public IActionNotifyHandler { +public: + Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) override { + if (event.kind != ActionTimelineEventKind::Notify) + return failure(DiagnosticCode::InvalidArgument, + "gameplay event block must be instantaneous", "event.kind"); + auto binding = ActionGameplayEventBinding::fromPayload(event.payload); + if (!binding) return Result::failure(binding.status()); + std::optional> result; + cap::forEachUntil([&](IActionGameplayEventSink* sink) { + result.emplace(sink->emit(binding.value(), context)); + return true; + }); + if (result) return std::move(*result); + return failure(DiagnosticCode::NotFound, "no gameplay event sink is registered", binding.value().tag); + } +}; + +class ActionStateWindowHandler final : public IActionNotifyHandler { +public: + Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) override { + if (event.kind != ActionTimelineEventKind::StateEnter && event.kind != ActionTimelineEventKind::StateExit) + return failure(DiagnosticCode::InvalidArgument, "state window requires enter or exit", "event.kind"); + auto binding = ActionStateWindowBinding::fromPayload(event.type.format(), event.payload); + if (!binding) return Result::failure(binding.status()); + std::optional> result; + cap::forEachUntil([&](IActionStateWindowSink* sink) { + if (!sink->supports(binding.value().kind)) return false; + result.emplace(event.kind == ActionTimelineEventKind::StateEnter + ? sink->enter(binding.value(), event, context) + : sink->exit(binding.value(), event, context)); + return true; + }); + if (result) return std::move(*result); + return failure(DiagnosticCode::NotFound, "no state-window sink accepts the action block", + event.type.format()); + } +}; + +class ActionParameterCurveHandler final : public IActionNotifyHandler { +public: + Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) override { + auto binding = ActionParameterCurveBinding::fromPayload(event.payload); + if (!binding) return Result::failure(binding.status()); + const ActiveKey key{context.executionId, event.itemId.format()}; + if (event.kind == ActionTimelineEventKind::StateEnter) + return Result::success(Status::success(StatusCode::NoOp)); + if (event.kind != ActionTimelineEventKind::StateExit) + return failure(DiagnosticCode::InvalidArgument, "parameter curve requires state boundaries", "event.kind"); + const auto found = active_.find(key); + if (found == active_.end()) + return Result::success(Status::success(StatusCode::NoOp)); + ActionParameterSample sample{ActionParameterPhase::End, context.executionId, event.itemId, + found->second.target, found->second.operation, found->second.value, + context.preview}; + auto applied = dispatch(sample); + if (!applied) return applied; + active_.erase(found); + return applied; + } + + Result update(const ActionActiveBlock& block, const ActionNotifyContext& context) override { + auto binding = ActionParameterCurveBinding::fromPayload(block.payload); + if (!binding) return Result::failure(binding.status()); + if (block.duration <= Duration::zero()) + return failure(DiagnosticCode::InvalidArgument, "parameter curve block duration must be positive", + "duration"); + const double progress = std::clamp(block.localTime.seconds() / block.duration.seconds(), 0.0, 1.0); + const double value = binding.value().sample(progress); + const ActiveKey key{context.executionId, block.itemId.format()}; + const auto found = active_.find(key); + const auto phase = found == active_.end() ? ActionParameterPhase::Begin : ActionParameterPhase::Update; + ActionParameterSample sample{phase, context.executionId, block.itemId, binding.value().target, + binding.value().operation, value, context.preview}; + auto applied = dispatch(sample); + if (!applied) return applied; + active_[key] = ActiveParameter{binding.value().target, binding.value().operation, value}; + return applied; + } + + Result sample(const ActionActiveBlock& block, const ActionNotifyContext&) const override { + auto binding = ActionParameterCurveBinding::fromPayload(block.payload); + if (!binding) return Result::failure(binding.status()); + if (block.duration <= Duration::zero()) + return failure(DiagnosticCode::InvalidArgument, "parameter curve block duration must be positive", + "duration"); + return Result::success(Status::success(StatusCode::NoOp)); + } + +private: + using ActiveKey = std::pair; + struct ActiveParameter { + LogicalId target; + ActionParameterOperation operation = ActionParameterOperation::Replace; + double value = 0.0; + }; + + static Result dispatch(const ActionParameterSample& sample) { + std::optional> result; + cap::forEachUntil([&](IActionParameterSink* sink) { + if (!sink->supports(sample.target)) return false; + result.emplace(sink->apply(sample)); + return true; + }); + if (result) return std::move(*result); + return failure(DiagnosticCode::NotFound, + "no parameter sink accepts the action target", sample.target.format()); + } + + std::map active_; +}; + } // namespace Result ActionNotifyRegistry::withBuiltins() { @@ -23,19 +187,46 @@ Result ActionNotifyRegistry::withBuiltins() { const std::vector builtins = { {"gameplay:event", "Gameplay Event", "Gameplay", ActionNotifyShape::Instant, {"tag"}}, {"combat:damage", "Apply Damage", "Combat", ActionNotifyShape::Instant, {"damageType", "amount"}}, - {"presentation:vfx", "Spawn VFX", "Presentation", ActionNotifyShape::Instant, {"uri"}}, + {"presentation:vfx", "Spawn VFX", "Presentation", ActionNotifyShape::Instant, {"uri", "lifetimeSeconds"}}, + {"presentation:vfx-state", "VFX State", "Presentation", ActionNotifyShape::State, {"uri"}}, {"presentation:audio", "Play Audio", "Presentation", ActionNotifyShape::Instant, {"uri"}}, + {"presentation:audio-state", "Audio State", "Presentation", ActionNotifyShape::State, {"uri"}}, + {"gameplay:prefab-spawn", "Spawn Prefab", "Gameplay", ActionNotifyShape::State, {"uri"}}, {"presentation:camera", "Camera Cue", "Presentation", ActionNotifyShape::Instant, {"cue"}}, {"combat:hitbox-window", "Hitbox Window", "Combat", ActionNotifyShape::State, {"hitbox"}}, {"combat:invulnerability-window", "Invulnerability Window", "Combat", ActionNotifyShape::State, {}}, {"input:combo-window", "Combo Window", "Input", ActionNotifyShape::State, {"input"}}, {"collision:ignore-window", "Collision Ignore", "Collision", ActionNotifyShape::State, {"channel"}}, {"movement:root-motion-window", "Root Motion", "Movement", ActionNotifyShape::State, {"mode"}}, + {"presentation:parameter-curve", "Parameter Curve", "Presentation", ActionNotifyShape::State, + {"target", "keys"}}, }; for (auto descriptor : builtins) { auto registered = registry.registerDescriptor(std::move(descriptor)); if (!registered) return Result::failure(registered.status()); } + auto cameraHandler = registry.registerHandler("presentation:camera", std::make_shared()); + if (!cameraHandler) return Result::failure(cameraHandler.status()); + auto damageHandler = registry.registerHandler("combat:damage", std::make_shared()); + if (!damageHandler) return Result::failure(damageHandler.status()); + auto gameplayEventHandler = + registry.registerHandler("gameplay:event", std::make_shared()); + if (!gameplayEventHandler) return Result::failure(gameplayEventHandler.status()); + auto stateWindowHandler = std::make_shared(); + for (const char* type : {"collision:ignore-window", "combat:hitbox-window", + "combat:invulnerability-window", "input:combo-window"}) { + auto registered = registry.registerHandler(type, stateWindowHandler); + if (!registered) return Result::failure(registered.status()); + } + auto parameterHandler = registry.registerHandler( + "presentation:parameter-curve", std::make_shared()); + if (!parameterHandler) return Result::failure(parameterHandler.status()); + Result providers = Result::success(Status::success(StatusCode::NoOp)); + cap::forEach([&](IActionNotifyProvider* provider) { + if (!providers) return; + providers = provider->install(registry); + }); + if (!providers) return Result::failure(providers.status()); return Result::success(std::move(registry)); } @@ -89,6 +280,8 @@ std::vector ActionNotifyRegistry::descriptors() const { return result; } +bool ActionNotifyRegistry::hasHandler(std::string_view type) const noexcept { return handlers_.contains(type); } + Result ActionNotifyRegistry::validate(const ActionTimelineEvent& event) const { auto found = descriptors_.find(event.type.format()); if (found == descriptors_.end()) @@ -100,6 +293,43 @@ Result ActionNotifyRegistry::validate(const ActionTimelineEvent& event) co for (const auto& field : found->second.requiredPayloadFields) if (!event.payload.contains(field)) return failure(DiagnosticCode::InvalidArgument, "Timeline event is missing required payload field", field); + if (event.type.format() == "gameplay:prefab-spawn") { + auto prefab = ActionPrefabSpawnBinding::fromPayload(event.payload); + if (!prefab) return Result::failure(prefab.status()); + } + if (event.type.format() == "presentation:audio" || event.type.format() == "presentation:audio-state") { + const auto shape = event.kind == ActionTimelineEventKind::Notify ? ActionAudioShape::Instant + : ActionAudioShape::State; + auto audio = ActionAudioBinding::fromPayload(event.payload, shape); + if (!audio) return Result::failure(audio.status()); + } + if (event.type.format() == "presentation:vfx" || event.type.format() == "presentation:vfx-state") { + const auto shape = event.kind == ActionTimelineEventKind::Notify ? ActionVfxShape::Instant + : ActionVfxShape::State; + auto vfx = ActionVfxBinding::fromPayload(event.payload, shape); + if (!vfx) return Result::failure(vfx.status()); + } + if (event.type.format() == "presentation:parameter-curve") { + auto curve = ActionParameterCurveBinding::fromPayload(event.payload); + if (!curve) return Result::failure(curve.status()); + } + if (event.type.format() == "presentation:camera") { + auto camera = ActionCameraCueBinding::fromPayload(event.payload); + if (!camera) return Result::failure(camera.status()); + } + if (event.type.format() == "combat:damage") { + auto damage = ActionDamageBinding::fromPayload(event.payload); + if (!damage) return Result::failure(damage.status()); + } + if (event.type.format() == "gameplay:event") { + auto gameplayEvent = ActionGameplayEventBinding::fromPayload(event.payload); + if (!gameplayEvent) return Result::failure(gameplayEvent.status()); + } + if (event.type.format() == "collision:ignore-window" || event.type.format() == "combat:hitbox-window" || + event.type.format() == "combat:invulnerability-window" || event.type.format() == "input:combo-window") { + auto window = ActionStateWindowBinding::fromPayload(event.type.format(), event.payload); + if (!window) return Result::failure(window.status()); + } return Result::success(); } @@ -113,4 +343,42 @@ Result ActionNotifyRegistry::dispatch(const ActionTimelineEvent& event, co return handler->second->handle(event, context); } +Result ActionNotifyRegistry::dispatchUpdate(const ActionActiveBlock& block, const ActionNotifyContext& context) { + ActionTimelineEvent event{ActionTimelineEventKind::StateEnter, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto valid = validate(event); + if (!valid) return valid; + const auto handler = handlers_.find(block.type.format()); + if (handler == handlers_.end()) + return failure(DiagnosticCode::NotFound, "No runtime handler is registered for notify type", + block.type.format()); + return handler->second->update(block, context); +} + +Result ActionNotifyRegistry::dispatchSample(const ActionActiveBlock& block, + const ActionNotifyContext& context) const { + ActionTimelineEvent event{ActionTimelineEventKind::StateEnter, block.trackId, block.itemId, block.type, + context.time, block.payload}; + auto valid = validate(event); + if (!valid) return valid; + const auto handler = handlers_.find(block.type.format()); + if (handler == handlers_.end()) + return failure(DiagnosticCode::NotFound, "No preview handler is registered for notify type", + block.type.format()); + return handler->second->sample(block, context); +} + +Result ActionNotifyRegistry::advanceHandlers(const ActionNotifyContext& context) { + std::set advanced; + StatusCode outcome = StatusCode::NoOp; + for (const auto& [type, handler] : handlers_) { + (void)type; + if (!advanced.insert(handler.get()).second) continue; + auto result = handler->advance(context); + if (!result) return result; + if (result.status().code() == StatusCode::Applied) outcome = StatusCode::Applied; + } + return Result::success(Status::success(outcome)); +} + } // namespace eve::action diff --git a/src/modules/action/ActionNotifyRegistry.h b/src/modules/action/ActionNotifyRegistry.h index 3fd39190..808981ce 100644 --- a/src/modules/action/ActionNotifyRegistry.h +++ b/src/modules/action/ActionNotifyRegistry.h @@ -3,6 +3,8 @@ /** @file ActionNotifyRegistry.h @brief Extensible notify descriptors, validation and runtime routing. */ #include "action/Action.h" +#include "common/AttachmentPoint.h" +#include "common/BorrowedRef.h" #include #include @@ -12,6 +14,8 @@ namespace eve::action { +class ActionNotifyRegistry; + /** @brief Declares whether a notify is instantaneous or an enter/exit state. */ enum class ActionNotifyShape : std::uint8_t { Instant, State }; @@ -26,9 +30,31 @@ struct ActionNotifyDescriptor { /** @brief Context linking a routed event to its action execution. */ struct ActionNotifyContext { + /** @brief Execution whose lifecycle is being routed. */ ActionExecutionId executionId; + /** @brief Optional source entity; resolved by consumers at call time. */ std::optional source; + /** @brief Owning target handle list for this synchronous dispatch. */ std::vector targets; + /** @brief Optional borrowed animated attachment source corresponding to source. */ + OptionalRef sourceAttachment; + /** + * @brief Borrowed animated attachment sources indexed like targets; empty entries are valid. + * @remarks References are valid only during synchronous dispatch and are never retained by handlers. + */ + std::vector> targetAttachments; + /** @brief Authoritative timeline time associated with this dispatch. */ + Duration time = Duration::zero(); + /** @brief Deterministic simulation tick associated with this dispatch, when available. */ + SimulationTick tick = SimulationTick::zero(); + /** @brief Finite positive host playback multiplier used by synchronized presentation blocks. */ + double playbackRate = 1.0; + /** @brief Whether the callback is an editor/runtime preview projection. */ + bool preview = false; + /** @brief Whether preview time was positioned discontinuously. */ + bool scrubbing = false; + /** @brief Whether state exits are being synthesized because the execution was interrupted. */ + bool interrupted = false; }; /** @@ -40,8 +66,62 @@ struct ActionNotifyContext { class IActionNotifyHandler { public: virtual ~IActionNotifyHandler() = default; - /** @brief Consume one already validated event. */ + /** + * @brief Consume one already validated event. + * @param event Owning boundary projection borrowed only for this call. + * @param context Owning dispatch context borrowed only for this call. + * @return Applied/NoOp or a structured consumer failure. + */ [[nodiscard]] virtual Result handle(const ActionTimelineEvent& event, const ActionNotifyContext& context) = 0; + /** + * @brief Update one active state block after its boundary events were delivered. + * @param block Owning active-state projection borrowed only for this call. + * @param context Owning dispatch context borrowed only for this call. + * @return Applied/NoOp or a structured consumer failure. + * @remarks The default is an explicit no-op for boundary-only handlers. + */ + [[nodiscard]] virtual Result update(const ActionActiveBlock&, const ActionNotifyContext&) { + return Result::success(Status::success(StatusCode::NoOp)); + } + /** + * @brief Evaluate one preview sample without creating persistent side effects. + * @param block Owning active-state projection borrowed only for this call. + * @param context Owning preview context borrowed only for this call. + * @return Applied/NoOp or a structured preview failure. + * @remarks Implementations must be side-effect-free and must not retain references. + */ + [[nodiscard]] virtual Result sample(const ActionActiveBlock&, const ActionNotifyContext&) const { + return Result::success(Status::success(StatusCode::NoOp)); + } + /** + * @brief Advance handler-owned transient instances after events and state updates. + * @param context Current deterministic action time and synchronous bindings. + * @return Applied/NoOp or a structured maintenance failure. + * @remarks Called once per unique handler instance per ActionBlockRuntime apply. + */ + [[nodiscard]] virtual Result advance(const ActionNotifyContext&) { + return Result::success(Status::success(StatusCode::NoOp)); + } +}; + +/** + * @brief Optional module provider that installs handlers into a new registry. + * + * Providers are discovered through the capability listener registry while + * `withBuiltins()` is constructing its candidate. The callback is synchronous, + * owner-thread-only and must not retain the borrowed registry reference. + */ +class IActionNotifyProvider { +public: + static constexpr const char* capabilityName = "eve.action.notify-provider"; + virtual ~IActionNotifyProvider() = default; + + /** + * @brief Install provider-owned handler instances for known descriptors. + * @param registry Borrowed candidate registry valid only for this call. + * @return Applied/NoOp or a structured installation failure. + */ + [[nodiscard]] virtual Result install(ActionNotifyRegistry& registry) = 0; }; /** @@ -55,20 +135,45 @@ class ActionNotifyRegistry { public: /** @brief Build a registry containing the engine's standard semantic notify types. */ [[nodiscard]] static Result withBuiltins(); - /** @brief Register a unique descriptor after validating its canonical type. */ + /** @brief Register a unique descriptor after validating its canonical type. @param descriptor Owning contract. */ [[nodiscard]] Result registerDescriptor(ActionNotifyDescriptor descriptor); - /** @brief Register an owning handler for an existing descriptor. */ + /** + * @brief Register an owning handler for an existing descriptor. + * @param type Registered type. + * @param handler Shared handler owner. + */ [[nodiscard]] Result registerHandler(std::string_view type, std::shared_ptr handler); - /** @brief Remove a handler while retaining its descriptor for authoring. */ + /** @brief Remove a handler while retaining its descriptor for authoring. @param type Registered type. */ [[nodiscard]] Result unregisterHandler(std::string_view type); - /** @brief Find an owning descriptor copy. */ + /** @brief Find an owning descriptor copy. @param type Registered type. */ [[nodiscard]] Result descriptor(std::string_view type) const; /** @brief Return descriptors in lexical type order. */ [[nodiscard]] std::vector descriptors() const; - /** @brief Validate type, shape and required payload fields. */ + /** @brief Return whether this registry has a runtime handler for the exact canonical type. */ + [[nodiscard]] bool hasHandler(std::string_view type) const noexcept; + /** @brief Validate type, shape and required payload fields. @param event Owning event projection. */ [[nodiscard]] Result validate(const ActionTimelineEvent& event) const; - /** @brief Validate and route an event, failing observably when no handler is present. */ + /** + * @brief Validate and route an event, failing observably when no handler is present. + * @param event Event projection. + * @param context Dispatch context. + */ [[nodiscard]] Result dispatch(const ActionTimelineEvent& event, const ActionNotifyContext& context); + /** + * @brief Route one authoritative active-state update to its registered handler. + * @param block Active block. + * @param context Dispatch context. + */ + [[nodiscard]] Result dispatchUpdate(const ActionActiveBlock& block, const ActionNotifyContext& context); + /** + * @brief Route one side-effect-free preview sample to its registered handler. + * @param block Active block. + * @param context Preview context. + */ + [[nodiscard]] Result dispatchSample(const ActionActiveBlock& block, + const ActionNotifyContext& context) const; + /** @brief Advance each unique registered handler once in lexical type order. */ + [[nodiscard]] Result advanceHandlers(const ActionNotifyContext& context); private: std::map> descriptors_; diff --git a/src/modules/action/ActionParameterCurve.cpp b/src/modules/action/ActionParameterCurve.cpp new file mode 100644 index 00000000..1ba2699c --- /dev/null +++ b/src/modules/action/ActionParameterCurve.cpp @@ -0,0 +1,170 @@ +#include "action/ActionParameterCurve.h" + +#include +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result number(const Value& value, std::string path) { + double result = 0.0; + if (const auto* integer = value.getIf()) + result = static_cast(*integer); + else if (const auto* decimal = value.getIf()) + result = *decimal; + else + return invalid("parameter curve field must be numeric", std::move(path)); + if (!std::isfinite(result)) return invalid("parameter curve field must be finite", std::move(path)); + return Result::success(result); +} + +std::optional parseInterpolation(std::string_view value) { + if (value == "step") return ActionParameterInterpolation::Step; + if (value == "linear") return ActionParameterInterpolation::Linear; + if (value == "cubic") return ActionParameterInterpolation::Cubic; + return std::nullopt; +} + +std::optional parseOperation(std::string_view value) { + if (value == "replace") return ActionParameterOperation::Replace; + if (value == "add") return ActionParameterOperation::Add; + if (value == "multiply") return ActionParameterOperation::Multiply; + return std::nullopt; +} + +} // namespace + +std::string_view actionParameterInterpolationName(ActionParameterInterpolation value) noexcept { + switch (value) { + case ActionParameterInterpolation::Step: return "step"; + case ActionParameterInterpolation::Linear: return "linear"; + case ActionParameterInterpolation::Cubic: return "cubic"; + } + return "linear"; +} + +std::optional actionParameterInterpolationFromName( + std::string_view value) noexcept { + return parseInterpolation(value); +} + +std::string_view actionParameterOperationName(ActionParameterOperation value) noexcept { + switch (value) { + case ActionParameterOperation::Replace: return "replace"; + case ActionParameterOperation::Add: return "add"; + case ActionParameterOperation::Multiply: return "multiply"; + } + return "replace"; +} + +Result ActionParameterCurveBinding::fromPayload(const Value::Object& payload) { + ActionParameterCurveBinding candidate; + const auto target = payload.find("target"); + if (target == payload.end() || !target->second.getIf()) + return invalid("parameter target must be text", "target"); + auto parsedTarget = LogicalId::parse(*target->second.getIf()); + if (!parsedTarget) + return invalid("parameter target must be a LogicalId", "target"); + candidate.target = std::move(*parsedTarget); + + if (const auto found = payload.find("operation"); found != payload.end()) { + const auto* text = found->second.getIf(); + if (!text || !parseOperation(*text)) + return invalid("parameter operation is unknown", "operation"); + candidate.operation = *parseOperation(*text); + } + + const auto foundKeys = payload.find("keys"); + const auto* keys = foundKeys == payload.end() ? nullptr : foundKeys->second.getIf(); + if (!keys || keys->size() < 2 || keys->size() > 1024) + return invalid("parameter curve requires 2 to 1024 keys", "keys"); + candidate.keys.reserve(keys->size()); + for (std::size_t index = 0; index < keys->size(); ++index) { + const auto* object = (*keys)[index].getIf(); + const std::string path = "keys[" + std::to_string(index) + "]"; + if (!object) return invalid("parameter key must be an object", path); + const auto time = object->find("time"); + const auto value = object->find("value"); + if (time == object->end() || value == object->end()) + return invalid("parameter key requires time and value", path); + auto parsedTime = number(time->second, path + ".time"); + if (!parsedTime) return Result::failure(parsedTime.status()); + auto parsedValue = number(value->second, path + ".value"); + if (!parsedValue) return Result::failure(parsedValue.status()); + if (parsedTime.value() < 0.0 || parsedTime.value() > 1.0 || + (!candidate.keys.empty() && parsedTime.value() <= candidate.keys.back().time)) + return invalid("parameter key times must be strictly ordered in [0, 1]", + path + ".time"); + ActionParameterKey key; + key.time = parsedTime.value(); + key.value = parsedValue.value(); + if (const auto tangent = object->find("inTangent"); tangent != object->end()) { + auto parsed = number(tangent->second, path + ".inTangent"); + if (!parsed) return Result::failure(parsed.status()); + key.inTangent = parsed.value(); + } + if (const auto tangent = object->find("outTangent"); tangent != object->end()) { + auto parsed = number(tangent->second, path + ".outTangent"); + if (!parsed) return Result::failure(parsed.status()); + key.outTangent = parsed.value(); + } + if (const auto mode = object->find("interpolation"); mode != object->end()) { + const auto* text = mode->second.getIf(); + if (!text || !parseInterpolation(*text)) + return invalid("parameter interpolation is unknown", + path + ".interpolation"); + key.interpolation = *parseInterpolation(*text); + } + candidate.keys.push_back(key); + } + if (candidate.keys.front().time != 0.0 || candidate.keys.back().time != 1.0) + return invalid("parameter curve endpoints must be at 0 and 1", "keys"); + return Result::success(std::move(candidate)); +} + +Value::Object ActionParameterCurveBinding::toPayload(Value::Object extensions) const { + extensions["target"] = target.format(); + extensions["operation"] = std::string(actionParameterOperationName(operation)); + Value::Array encoded; + encoded.reserve(keys.size()); + for (const auto& key : keys) + encoded.emplace_back(Value::Object{{"time", key.time}, + {"value", key.value}, + {"inTangent", key.inTangent}, + {"outTangent", key.outTangent}, + {"interpolation", std::string(actionParameterInterpolationName( + key.interpolation))}}); + extensions["keys"] = Value(std::move(encoded)); + return extensions; +} + +double ActionParameterCurveBinding::sample(double progress) const noexcept { + if (keys.empty()) return 0.0; + progress = std::clamp(progress, 0.0, 1.0); + const auto upper = std::upper_bound(keys.begin(), keys.end(), progress, + [](double value, const ActionParameterKey& key) { return value < key.time; }); + if (upper == keys.begin()) return keys.front().value; + if (upper == keys.end()) return keys.back().value; + const auto& left = *(upper - 1); + const auto& right = *upper; + if (left.interpolation == ActionParameterInterpolation::Step) return left.value; + const double span = right.time - left.time; + const double t = (progress - left.time) / span; + if (left.interpolation == ActionParameterInterpolation::Linear) + return left.value + (right.value - left.value) * t; + const double t2 = t * t; + const double t3 = t2 * t; + return (2.0 * t3 - 3.0 * t2 + 1.0) * left.value + + (t3 - 2.0 * t2 + t) * left.outTangent * span + + (-2.0 * t3 + 3.0 * t2) * right.value + + (t3 - t2) * right.inTangent * span; +} + +} // namespace eve::action diff --git a/src/modules/action/ActionParameterCurve.h b/src/modules/action/ActionParameterCurve.h new file mode 100644 index 00000000..5ba16055 --- /dev/null +++ b/src/modules/action/ActionParameterCurve.h @@ -0,0 +1,103 @@ +#pragma once + +/** @file ActionParameterCurve.h @brief Typed continuous parameter-curve action block. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include +#include +#include +#include + +namespace eve::action { + +/** @brief Interpolation used from one parameter key to the following key. */ +enum class ActionParameterInterpolation : std::uint8_t { Step, Linear, Cubic }; + +/** @brief Composition operation applied by a target-specific parameter sink. */ +enum class ActionParameterOperation : std::uint8_t { Replace, Add, Multiply }; + +/** @brief One normalized, owning parameter-curve key. */ +struct ActionParameterKey { + /** @brief Normalized key time in [0, 1]. */ + double time = 0.0; + /** @brief Authored scalar value. */ + double value = 0.0; + /** @brief Incoming value-per-normalized-time tangent for cubic interpolation. */ + double inTangent = 0.0; + /** @brief Outgoing value-per-normalized-time tangent for cubic interpolation. */ + double outTangent = 0.0; + /** @brief Interpolation from this key to the following key. */ + ActionParameterInterpolation interpolation = ActionParameterInterpolation::Linear; + + auto operator<=>(const ActionParameterKey&) const = default; +}; + +/** @brief Validated owning continuous parameter binding. */ +struct ActionParameterCurveBinding { + /** @brief Stable sink-owned parameter target. */ + LogicalId target; + /** @brief Composition operation applied at the target. */ + ActionParameterOperation operation = ActionParameterOperation::Replace; + /** @brief Strictly ordered normalized keys, including endpoints 0 and 1. */ + std::vector keys; + + auto operator<=>(const ActionParameterCurveBinding&) const = default; + + /** @brief Decode and validate target, operation and normalized ordered keys. */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); + /** @brief Encode the canonical fields while preserving unrelated extension fields. */ + [[nodiscard]] Value::Object toPayload(Value::Object extensions = {}) const; + /** @brief Evaluate the curve at normalized block progress in [0, 1]. */ + [[nodiscard]] double sample(double progress) const noexcept; +}; + +/** @brief Stable persisted interpolation spelling. */ +[[nodiscard]] std::string_view actionParameterInterpolationName(ActionParameterInterpolation value) noexcept; +/** @brief Parse a stable persisted interpolation spelling. */ +[[nodiscard]] std::optional actionParameterInterpolationFromName( + std::string_view value) noexcept; +/** @brief Stable persisted operation spelling. */ +[[nodiscard]] std::string_view actionParameterOperationName(ActionParameterOperation value) noexcept; + +/** @brief Parameter lifecycle projected to a target-specific optional sink. */ +enum class ActionParameterPhase : std::uint8_t { Begin, Update, End }; + +/** @brief Owning continuous parameter sample emitted by ActionBlockRuntime. */ +struct ActionParameterSample { + /** @brief Paired lifecycle phase. */ + ActionParameterPhase phase = ActionParameterPhase::Update; + /** @brief Generation-qualified action execution identity. */ + ActionExecutionId executionId; + /** @brief Stable timeline item identity within the execution. */ + LogicalId itemId; + /** @brief Stable sink-owned parameter target. */ + LogicalId target; + /** @brief Composition operation for this curve. */ + ActionParameterOperation operation = ActionParameterOperation::Replace; + /** @brief Evaluated scalar value. */ + double value = 0.0; + /** @brief Whether the sample belongs to non-authoritative preview evaluation. */ + bool preview = false; +}; + +/** + * @brief Optional open target for continuous action parameters. + * + * Implementations own target state and must make Begin/Update/End pairing + * deterministic for generation-qualified execution/item identity. Calls are + * owner-thread-only and must not retain the borrowed sample. + */ +class IActionParameterSink { +public: + static constexpr const char* capabilityName = "IActionParameterSink"; + virtual ~IActionParameterSink() = default; + + /** @brief Whether this sink owns the supplied stable target. */ + [[nodiscard]] virtual bool supports(const LogicalId& target) const noexcept = 0; + /** @brief Apply one borrowed lifecycle sample and report any target failure. */ + [[nodiscard]] virtual Result apply(const ActionParameterSample& sample) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionPrefabBlock.cpp b/src/modules/action/ActionPrefabBlock.cpp new file mode 100644 index 00000000..57612d47 --- /dev/null +++ b/src/modules/action/ActionPrefabBlock.cpp @@ -0,0 +1,91 @@ +#include "action/ActionPrefabBlock.h" + +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result requiredText(const Value::Object& payload, std::string_view key) { + const auto found = payload.find(std::string(key)); + if (found == payload.end() || !found->second.getIf()) + return invalid("prefab field must be text", std::string(key)); + if (found->second.getIf()->empty()) + return invalid("prefab field must not be empty", std::string(key)); + return Result::success(*found->second.getIf()); +} + +Result optionalNumber(const Value::Object& payload, std::string_view key, double fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(fallback); + double value = 0.0; + if (const auto* decimal = found->second.getIf()) + value = *decimal; + else if (const auto* integer = found->second.getIf()) + value = static_cast(*integer); + else + return invalid("prefab duration must be numeric", std::string(key)); + if (!std::isfinite(value)) + return invalid("prefab duration must be finite", std::string(key)); + return Result::success(value); +} + +} // namespace + +std::string_view prefabSpawnLifecycleName(PrefabSpawnLifecycle lifecycle) noexcept { + switch (lifecycle) { + case PrefabSpawnLifecycle::RecycleOnBlockExit: return "recycle_on_block_exit"; + case PrefabSpawnLifecycle::CustomDuration: return "custom_duration"; + case PrefabSpawnLifecycle::Independent: return "independent"; + } + return "recycle_on_block_exit"; +} + +Result ActionPrefabSpawnBinding::fromPayload(const Value::Object& payload) { + ActionPrefabSpawnBinding candidate; + auto uri = requiredText(payload, "uri"); + if (!uri) return Result::failure(uri.status()); + candidate.uri = std::move(uri).takeValue(); + + std::string lifecycle = "recycle_on_block_exit"; + if (const auto found = payload.find("lifecycle"); found != payload.end()) { + const auto* value = found->second.getIf(); + if (!value) return invalid("prefab lifecycle must be text", "lifecycle"); + lifecycle = *value; + } + if (lifecycle == "recycle_on_block_exit") + candidate.lifecycle = PrefabSpawnLifecycle::RecycleOnBlockExit; + else if (lifecycle == "custom_duration") + candidate.lifecycle = PrefabSpawnLifecycle::CustomDuration; + else if (lifecycle == "independent") + candidate.lifecycle = PrefabSpawnLifecycle::Independent; + else + return invalid("unknown prefab lifecycle", "lifecycle"); + + auto duration = optionalNumber(payload, "customDurationSeconds", 0.0); + if (!duration) return Result::failure(duration.status()); + if (payload.contains("customDurationSeconds") && duration.value() <= 0.0) + return invalid("custom prefab duration must be positive", + "customDurationSeconds"); + if (candidate.lifecycle == PrefabSpawnLifecycle::CustomDuration) { + if (!payload.contains("customDurationSeconds")) + return invalid("custom prefab duration must be positive", + "customDurationSeconds"); + auto converted = Duration::fromSeconds(duration.value()); + if (!converted) return Result::failure(converted.status()); + candidate.customDuration = std::move(converted).takeValue(); + } + + auto spatial = ActionSpatialBinding::fromPayload(payload); + if (!spatial) return Result::failure(spatial.status()); + candidate.spatial = std::move(spatial).takeValue(); + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionPrefabBlock.h b/src/modules/action/ActionPrefabBlock.h new file mode 100644 index 00000000..d7c4055c --- /dev/null +++ b/src/modules/action/ActionPrefabBlock.h @@ -0,0 +1,51 @@ +#pragma once + +/** @file ActionPrefabBlock.h @brief Typed payload contract for prefab-spawn action states. */ + +#include "action/ActionSpatialBlock.h" +#include "common/Time.h" + +#include +#include +#include + +namespace eve::action { + +/** @brief Authority responsible for ending a spawned prefab instance. */ +enum class PrefabSpawnLifecycle : std::uint8_t { + /** @brief The action block recycles the instance on Exit or interruption. */ + RecycleOnBlockExit, + /** @brief The action block recycles the instance after customDuration. */ + CustomDuration, + /** @brief Ownership is transferred to the prefab instance service after spawning. */ + Independent, +}; + +/** + * @brief Fully validated, owning settings for one prefab-spawn state block. + * + * This is the renderer-neutral contract shared by editor validation and the + * runtime provider. It stores no entity, scene-node, resource, or bone pointer. + */ +struct ActionPrefabSpawnBinding { + std::string uri; + PrefabSpawnLifecycle lifecycle = PrefabSpawnLifecycle::RecycleOnBlockExit; + Duration customDuration = Duration::zero(); + ActionSpatialBinding spatial; + + auto operator<=>(const ActionPrefabSpawnBinding&) const = default; + + /** + * @brief Decode a prefab-spawn payload without mutating runtime state. + * @param payload Owning action payload containing uri, lifecycle and spatial fields. + * @return Validated settings or a stable field-path diagnostic. + * @remarks lifecycle defaults to recycle_on_block_exit. custom_duration requires + * a positive finite customDurationSeconds value. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** @brief Return the stable serialized spelling for a prefab lifecycle. */ +[[nodiscard]] std::string_view prefabSpawnLifecycleName(PrefabSpawnLifecycle lifecycle) noexcept; + +} // namespace eve::action diff --git a/src/modules/action/ActionPrefabInstances.h b/src/modules/action/ActionPrefabInstances.h new file mode 100644 index 00000000..f0f1e24a --- /dev/null +++ b/src/modules/action/ActionPrefabInstances.h @@ -0,0 +1,53 @@ +#pragma once + +/** @file ActionPrefabInstances.h @brief External ownership boundary for independent prefab spawns. */ + +#include "common/Result.h" +#include "common/RuntimeHandle.h" + +#include +#include + +namespace eve::action { + +/** @brief Owner tag preventing prefab instance handles from mixing with other registries. */ +struct PrefabInstanceHandleTag {}; + +/** @brief Process-local, generation-qualified identity of one spawned prefab instance. */ +using PrefabInstanceHandle = RuntimeHandle; + +/** @brief Owning inspection record for one externally managed prefab instance. */ +struct PrefabInstanceInfo { + PrefabInstanceHandle handle; + std::string uri; +}; + +/** + * @brief Optional owner-thread service for prefab instances detached from an action block. + * + * The providing scene/presentation module remains the authoritative owner. The + * caller receives handles and owning metadata only; no ECS or renderer pointer + * crosses the module boundary. + */ +class IActionPrefabInstances { +public: + static constexpr const char* capabilityName = "eve.action.prefab-instances"; + virtual ~IActionPrefabInstances() = default; + + /** + * @brief List current independent instances in stable slot order. + * @return Owning records; active action-owned and pooled instances are excluded. + * @thread Owner/render thread only. + */ + [[nodiscard]] virtual std::vector independentInstances() const = 0; + + /** + * @brief Recycle one independent instance and invalidate its handle. + * @param handle Generation-qualified identity previously returned by independentInstances(). + * @return Applied, or NotFound for an invalid/stale/non-independent handle. + * @thread Owner/render thread only; invokes no script callbacks. + */ + [[nodiscard]] virtual Result recycleIndependent(PrefabInstanceHandle handle) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionPreview.h b/src/modules/action/ActionPreview.h index 3bd42b11..d19fba65 100644 --- a/src/modules/action/ActionPreview.h +++ b/src/modules/action/ActionPreview.h @@ -5,6 +5,7 @@ #include "action/ActionTimeline.h" #include +#include #include #include #include @@ -43,6 +44,8 @@ struct ActionPreviewFrame { Duration previous; Duration current; std::vector cues; + /** @brief Owning state-block samples active at current for isolated scene reconciliation. */ + std::vector activeBlocks; RootMotionPreviewState rootMotionState = RootMotionPreviewState::Unavailable; std::vector rootMotionPath; }; @@ -64,6 +67,21 @@ public: virtual void discardPrepared() noexcept = 0; }; +/** + * @brief Optional owner-thread provider of one independently owned action preview layer. + * + * Multiple providers are composed in capability-listener order. The provider + * owns no returned sink. Callers own and destroy each sink before unloading + * its implementation module. Creation is synchronous and non-reentrant. + */ +class IActionPreviewSinkProvider { +public: + static constexpr const char* capabilityName = "eve.action.preview-sink-provider"; + virtual ~IActionPreviewSinkProvider() = default; + /** @brief Create one isolated preview sink owned by the caller. */ + [[nodiscard]] virtual Result> createActionPreviewSink() = 0; +}; + /** @brief Optional animation provider that samples an owning root-motion polyline. */ class IActionRootMotionSource { public: diff --git a/src/modules/action/ActionSpatialBlock.cpp b/src/modules/action/ActionSpatialBlock.cpp new file mode 100644 index 00000000..e9b19dcd --- /dev/null +++ b/src/modules/action/ActionSpatialBlock.cpp @@ -0,0 +1,109 @@ +#include "action/ActionSpatialBlock.h" + +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result number(const Value& value, std::string path) { + double result = 0.0; + if (const auto* integer = value.getIf()) + result = static_cast(*integer); + else if (const auto* decimal = value.getIf()) + result = *decimal; + else + return invalid("spatial component must be numeric", std::move(path)); + if (!std::isfinite(result)) return invalid("spatial component must be finite", std::move(path)); + return Result::success(result); +} + +Result vector(const Value::Object& payload, std::string_view key, + ActionSpatialVector3 fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(fallback); + const auto* values = found->second.getIf(); + if (!values || values->size() != 3) + return invalid("spatial vector must contain exactly three numbers", std::string(key)); + ActionSpatialVector3 result; + double* components[] = {&result.x, &result.y, &result.z}; + for (std::size_t index = 0; index < 3; ++index) { + auto parsed = number((*values)[index], std::string(key) + "[" + std::to_string(index) + "]"); + if (!parsed) return Result::failure(parsed.status()); + *components[index] = std::move(parsed).takeValue(); + } + return Result::success(result); +} + +Result text(const Value::Object& payload, std::string_view key, std::string fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(std::move(fallback)); + const auto* value = found->second.getIf(); + if (!value) return invalid("spatial field must be text", std::string(key)); + return Result::success(*value); +} + +} // namespace + +std::string_view actionSpatialAttachmentModeName(ActionSpatialAttachmentMode mode) noexcept { + switch (mode) { + case ActionSpatialAttachmentMode::FollowTarget: return "follow_target"; + case ActionSpatialAttachmentMode::FollowPositionOnly: return "follow_position_only"; + case ActionSpatialAttachmentMode::WorldTransformAtStart: return "world_transform_at_start"; + } + return "follow_target"; +} + +Result ActionSpatialBinding::fromPayload(const Value::Object& payload) { + ActionSpatialBinding candidate; + auto mode = text(payload, "attachment", "follow_target"); + if (!mode) return Result::failure(mode.status()); + if (mode.value() == "follow_target") + candidate.mode = ActionSpatialAttachmentMode::FollowTarget; + else if (mode.value() == "follow_position_only") + candidate.mode = ActionSpatialAttachmentMode::FollowPositionOnly; + else if (mode.value() == "world_transform_at_start") + candidate.mode = ActionSpatialAttachmentMode::WorldTransformAtStart; + else + return invalid("unknown spatial attachment mode", "attachment"); + + auto target = text(payload, "spatialTarget", "source"); + if (!target) return Result::failure(target.status()); + if (target.value() == "source") + candidate.target = ActionSpatialTarget::Source; + else if (target.value() == "target") + candidate.target = ActionSpatialTarget::Target; + else + return invalid("unknown spatial target", "spatialTarget"); + + if (const auto found = payload.find("targetIndex"); found != payload.end()) { + const auto* index = found->second.getIf(); + if (!index || *index < 0 || static_cast(*index) > std::numeric_limits::max()) + return invalid("target index must be a non-negative integer", "targetIndex"); + candidate.targetIndex = static_cast(*index); + } + auto bone = text(payload, "bone", {}); + if (!bone) return Result::failure(bone.status()); + candidate.bone = std::move(bone).takeValue(); + + auto position = vector(payload, "positionOffset", {}); + if (!position) return Result::failure(position.status()); + candidate.positionOffset = std::move(position).takeValue(); + auto rotation = vector(payload, "rotationOffsetDegrees", {}); + if (!rotation) return Result::failure(rotation.status()); + candidate.rotationOffsetDegrees = std::move(rotation).takeValue(); + auto scale = vector(payload, "scale", {1.0, 1.0, 1.0}); + if (!scale) return Result::failure(scale.status()); + candidate.scale = std::move(scale).takeValue(); + if (candidate.scale.x <= 0.0 || candidate.scale.y <= 0.0 || candidate.scale.z <= 0.0) + return invalid("spatial scale components must be positive", "scale"); + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionSpatialBlock.h b/src/modules/action/ActionSpatialBlock.h new file mode 100644 index 00000000..5c244632 --- /dev/null +++ b/src/modules/action/ActionSpatialBlock.h @@ -0,0 +1,68 @@ +#pragma once + +/** @file ActionSpatialBlock.h @brief Typed spatial payload shared by presentation action blocks. */ + +#include "common/Result.h" +#include "common/Value.h" + +#include +#include +#include +#include + +namespace eve::action { + +/** @brief How a spawned presentation object follows its resolved anchor. */ +enum class ActionSpatialAttachmentMode : std::uint8_t { + /** @brief Re-evaluate position and rotation from the anchor every update. */ + FollowTarget, + /** @brief Re-evaluate position only and retain the spawn rotation. */ + FollowPositionOnly, + /** @brief Capture the complete world transform once on Enter. */ + WorldTransformAtStart, +}; + +/** @brief Which handle in ActionNotifyContext supplies the spatial anchor. */ +enum class ActionSpatialTarget : std::uint8_t { Source, Target }; + +/** @brief Owning three-component value used at the action/presentation boundary. */ +struct ActionSpatialVector3 { + double x = 0.0; + double y = 0.0; + double z = 0.0; + + auto operator<=>(const ActionSpatialVector3&) const = default; +}; + +/** + * @brief Validated spatial settings decoded from a notify payload. + * + * The action module owns only these backend-neutral values. Scene, animation, + * audio and VFX adapters resolve entity handles and bone names synchronously; + * this value never stores a scene node, component pointer or bone pointer. + */ +struct ActionSpatialBinding { + ActionSpatialAttachmentMode mode = ActionSpatialAttachmentMode::FollowTarget; + ActionSpatialTarget target = ActionSpatialTarget::Source; + std::size_t targetIndex = 0; + std::string bone; + ActionSpatialVector3 positionOffset; + ActionSpatialVector3 rotationOffsetDegrees; + ActionSpatialVector3 scale{1.0, 1.0, 1.0}; + + auto operator<=>(const ActionSpatialBinding&) const = default; + + /** + * @brief Decode and validate the common spatial fields in an owning payload. + * @param payload Notify payload; unrelated provider-specific fields are ignored. + * @return Typed settings or a stable field-path diagnostic without mutation. + * @remarks Accepted vector fields are three-element numeric arrays. All + * components must be finite and scale components must be positive. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload); +}; + +/** @brief Return the stable payload spelling for an attachment mode. */ +[[nodiscard]] std::string_view actionSpatialAttachmentModeName(ActionSpatialAttachmentMode mode) noexcept; + +} // namespace eve::action diff --git a/src/modules/action/ActionStateWindowBlock.cpp b/src/modules/action/ActionStateWindowBlock.cpp new file mode 100644 index 00000000..215f8ea4 --- /dev/null +++ b/src/modules/action/ActionStateWindowBlock.cpp @@ -0,0 +1,62 @@ +#include "action/ActionStateWindowBlock.h" + +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result> parseTargetIndex(const Value::Object& payload) { + const auto found = payload.find("targetIndex"); + if (found == payload.end()) return Result>::success(std::nullopt); + const auto* value = found->second.getIf(); + if (!value || *value < 0 || static_cast(*value) > std::numeric_limits::max()) + return invalid>( + "state window target index must be an unsigned 32-bit integer", "targetIndex"); + return Result>::success(static_cast(*value)); +} + +Result requiredString(const Value::Object& payload, const char* field) { + const auto found = payload.find(field); + const auto* value = found == payload.end() ? nullptr : found->second.getIf(); + if (!value || value->empty()) return invalid("state window resource must be non-empty", field); + return Result::success(*value); +} + +} // namespace + +Result ActionStateWindowBinding::fromPayload(std::string_view type, + const Value::Object& payload) { + ActionStateWindowBinding candidate; + auto target = parseTargetIndex(payload); + if (!target) return Result::failure(target.status()); + candidate.targetIndex = target.value(); + const char* field = nullptr; + if (type == "combat:hitbox-window") { + candidate.kind = ActionStateWindowKind::Hitbox; + field = "hitbox"; + } else if (type == "combat:invulnerability-window") { + candidate.kind = ActionStateWindowKind::Invulnerability; + } else if (type == "input:combo-window") { + candidate.kind = ActionStateWindowKind::Combo; + field = "input"; + } else if (type == "collision:ignore-window") { + candidate.kind = ActionStateWindowKind::CollisionIgnore; + field = "channel"; + } else { + return invalid("unknown built-in state window type", "type"); + } + if (field) { + auto resource = requiredString(payload, field); + if (!resource) return Result::failure(resource.status()); + candidate.resource = std::move(resource).takeValue(); + } + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionStateWindowBlock.h b/src/modules/action/ActionStateWindowBlock.h new file mode 100644 index 00000000..1e044739 --- /dev/null +++ b/src/modules/action/ActionStateWindowBlock.h @@ -0,0 +1,51 @@ +#pragma once + +/** @file ActionStateWindowBlock.h @brief Typed combat/input/collision action-state window contracts. */ + +#include "action/ActionNotifyRegistry.h" + +#include +#include + +namespace eve::action { + +/** @brief Stable semantic kind for one built-in state window. */ +enum class ActionStateWindowKind : std::uint8_t { Hitbox, Invulnerability, Combo, CollisionIgnore }; + +/** @brief Owning validated state-window request authored on an action timeline. */ +struct ActionStateWindowBinding { + ActionStateWindowKind kind = ActionStateWindowKind::Hitbox; + /** @brief Hitbox, input action, or collision channel identifier; empty for invulnerability. */ + std::string resource; + /** @brief Optional target index; absence selects ActionNotifyContext::source. */ + std::optional targetIndex; + + /** @brief Decode one known state-window payload without mutating domain state. */ + [[nodiscard]] static Result fromPayload(std::string_view type, + const Value::Object& payload); +}; + +/** + * @brief Optional target-domain adapter for paired action state windows. + * + * Implementations own all projected mutable state. Enter and exit are synchronous, + * owner-thread-only, and may not retain references to the event or context. + */ +class IActionStateWindowSink { +public: + static constexpr const char* capabilityName = "eve.action.state-window-sink"; + virtual ~IActionStateWindowSink() = default; + + /** @brief Whether this sink owns the supplied semantic window kind. */ + [[nodiscard]] virtual bool supports(ActionStateWindowKind kind) const noexcept = 0; + /** @brief Enter one validated window or idempotently observe the same active key. */ + [[nodiscard]] virtual Result enter(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) = 0; + /** @brief Exit one previously entered window by execution and item identity. */ + [[nodiscard]] virtual Result exit(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionTimeline.cpp b/src/modules/action/ActionTimeline.cpp index 7bcdab1f..5a7fbdd7 100644 --- a/src/modules/action/ActionTimeline.cpp +++ b/src/modules/action/ActionTimeline.cpp @@ -61,6 +61,7 @@ Value encodeNotify(const ActionNotify& notify) { object["type"] = notify.type.format(); object["timeNs"] = notify.time.nanoseconds(); object["payload"] = payloadValue(notify.payload); + object["enabled"] = notify.enabled; return Value(std::move(object)); } @@ -71,6 +72,7 @@ Value encodeState(const ActionNotifyState& state) { object["startNs"] = state.start.nanoseconds(); object["endNs"] = state.end.nanoseconds(); object["payload"] = payloadValue(state.payload); + object["enabled"] = state.enabled; return Value(std::move(object)); } @@ -119,7 +121,7 @@ Result decodeAnimationSection(const Value& value, const return Result::success(std::move(section)); } -Result decodeNotify(const Value& value, const std::string& path) { +Result decodeNotify(const Value& value, const std::string& path, std::int64_t schemaVersion) { if (!value.isObject()) return invalid("expected notify object", path); auto id = logicalIdField(value, "id", path + ".id"); if (!id) return Result::failure(id.status()); @@ -129,12 +131,18 @@ Result decodeNotify(const Value& value, const std::string& path) { if (!time) return Result::failure(time.status()); auto payload = payloadField(value, path + ".payload"); if (!payload) return Result::failure(payload.status()); + bool enabled = true; + if (schemaVersion >= 4) { + auto decodedEnabled = boolField(value, "enabled", path + ".enabled"); + if (!decodedEnabled) return Result::failure(decodedEnabled.status()); + enabled = decodedEnabled.value(); + } return Result::success(ActionNotify{std::move(id).takeValue(), std::move(type).takeValue(), Duration::fromNanoseconds(time.value()), - std::move(payload).takeValue()}); + std::move(payload).takeValue(), enabled}); } -Result decodeState(const Value& value, const std::string& path) { +Result decodeState(const Value& value, const std::string& path, std::int64_t schemaVersion) { if (!value.isObject()) return invalid("expected notify-state object", path); auto id = logicalIdField(value, "id", path + ".id"); if (!id) return Result::failure(id.status()); @@ -146,9 +154,15 @@ Result decodeState(const Value& value, const std::string& pat if (!end) return Result::failure(end.status()); auto payload = payloadField(value, path + ".payload"); if (!payload) return Result::failure(payload.status()); + bool enabled = true; + if (schemaVersion >= 4) { + auto decodedEnabled = boolField(value, "enabled", path + ".enabled"); + if (!decodedEnabled) return Result::failure(decodedEnabled.status()); + enabled = decodedEnabled.value(); + } return Result::success(ActionNotifyState{ std::move(id).takeValue(), std::move(type).takeValue(), Duration::fromNanoseconds(start.value()), - Duration::fromNanoseconds(end.value()), std::move(payload).takeValue()}); + Duration::fromNanoseconds(end.value()), std::move(payload).takeValue(), enabled}); } bool inWindow(Duration value, Duration previous, Duration current, bool includePrevious) { @@ -273,11 +287,14 @@ Result> ActionTimeline::sample(Duration previou std::vector out; for (const auto& track : tracks) { if (track.muted) continue; - for (const auto& notify : track.notifies) + for (const auto& notify : track.notifies) { + if (!notify.enabled) continue; if (inWindow(notify.time, previous, current, includePrevious)) out.push_back( {ActionTimelineEventKind::Notify, track.id, notify.id, notify.type, notify.time, notify.payload}); + } for (const auto& state : track.states) { + if (!state.enabled) continue; if (inWindow(state.start, previous, current, includePrevious)) out.push_back( {ActionTimelineEventKind::StateEnter, track.id, state.id, state.type, state.start, state.payload}); @@ -295,6 +312,33 @@ Result> ActionTimeline::sample(Duration previou return Result>::success(std::move(out)); } +Result> ActionTimeline::activeBlocks(Duration time) const { + auto valid = validate(); + if (!valid) return Result>::failure(valid.status()); + if (time < Duration::zero() || time > duration) + return invalid>("active-block sample is outside the timeline", "time"); + + std::vector out; + for (const auto& track : tracks) { + if (track.muted) continue; + for (const auto& state : track.states) { + if (!state.enabled) continue; + if (time < state.start || time >= state.end) continue; + out.push_back({track.id, + state.id, + state.type, + Duration::fromNanoseconds(time.nanoseconds() - state.start.nanoseconds()), + Duration::fromNanoseconds(state.end.nanoseconds() - state.start.nanoseconds()), + state.payload}); + } + } + std::stable_sort(out.begin(), out.end(), [](const auto& left, const auto& right) { + if (left.trackId != right.trackId) return left.trackId.format() < right.trackId.format(); + return left.itemId.format() < right.itemId.format(); + }); + return Result>::success(std::move(out)); +} + Result ActionTimeline::toValue() const { auto valid = validate(); if (!valid) return Result::failure(valid.status()); @@ -462,13 +506,15 @@ Result ActionTimeline::fromValue(const Value& value) { track.locked = locked.value(); const auto& notifyValues = *notifies->getIf(); for (std::size_t index = 0; index < notifyValues.size(); ++index) { - auto decoded = decodeNotify(notifyValues[index], path + ".notifies[" + std::to_string(index) + "]"); + auto decoded = decodeNotify(notifyValues[index], path + ".notifies[" + std::to_string(index) + "]", + version.value()); if (!decoded) return Result::failure(decoded.status()); track.notifies.push_back(std::move(decoded).takeValue()); } const auto& stateValues = *states->getIf(); for (std::size_t index = 0; index < stateValues.size(); ++index) { - auto decoded = decodeState(stateValues[index], path + ".states[" + std::to_string(index) + "]"); + auto decoded = decodeState(stateValues[index], path + ".states[" + std::to_string(index) + "]", + version.value()); if (!decoded) return Result::failure(decoded.status()); track.states.push_back(std::move(decoded).takeValue()); } diff --git a/src/modules/action/ActionTimeline.h b/src/modules/action/ActionTimeline.h index 9bafa5e8..207b71d9 100644 --- a/src/modules/action/ActionTimeline.h +++ b/src/modules/action/ActionTimeline.h @@ -22,7 +22,7 @@ namespace eve::action { /** @brief Canonical schema identifier for persisted action timelines. */ inline constexpr std::string_view kActionTimelineSchemaId = "eve.action.timeline"; /** @brief Current action timeline schema version. */ -inline constexpr std::uint64_t kActionTimelineSchemaVersion = 3; +inline constexpr std::uint64_t kActionTimelineSchemaVersion = 4; /** @brief Built-in deterministic cross-fade curve evaluated without asset callbacks. */ enum class ActionBlendCurve : std::uint8_t { @@ -96,6 +96,7 @@ struct ActionNotify { LogicalId type; Duration time = Duration::zero(); Value::Object payload; + bool enabled = true; auto operator<=>(const ActionNotify&) const = default; }; @@ -107,6 +108,7 @@ struct ActionNotifyState { Duration start = Duration::zero(); Duration end = Duration::zero(); Value::Object payload; + bool enabled = true; auto operator<=>(const ActionNotifyState&) const = default; }; @@ -144,6 +146,18 @@ struct ActionTimelineEvent { auto operator<=>(const ActionTimelineEvent&) const = default; }; +/** @brief Owning per-step sample of one currently active timeline state. */ +struct ActionActiveBlock { + LogicalId trackId; + LogicalId itemId; + LogicalId type; + Duration localTime = Duration::zero(); + Duration duration = Duration::zero(); + Value::Object payload; + + auto operator<=>(const ActionActiveBlock&) const = default; +}; + /** * @brief Canonical action timeline asset shared by runtime and editor. * @@ -179,6 +193,13 @@ struct ActionTimeline { [[nodiscard]] Result> sample(Duration previous, Duration current, bool includePrevious = false) const; + /** + * @brief Sample every unmuted state active at one authoritative timeline time. + * @param time Timestamp in the closed timeline range; state ends remain exclusive. + * @return Owning blocks in stable track/item order, or a validation/range diagnostic. + */ + [[nodiscard]] Result> activeBlocks(Duration time) const; + /** @brief Encode the canonical schema as an owning deterministic Value. */ [[nodiscard]] Result toValue() const; /** @brief Decode and validate one canonical schema value transactionally. */ diff --git a/src/modules/action/ActionVfxBlock.cpp b/src/modules/action/ActionVfxBlock.cpp new file mode 100644 index 00000000..2484796d --- /dev/null +++ b/src/modules/action/ActionVfxBlock.cpp @@ -0,0 +1,83 @@ +#include "action/ActionVfxBlock.h" + +#include +#include +#include + +namespace eve::action { +namespace { + +template +Result invalid(std::string message, std::string path) { + return Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument, std::move(message), std::move(path))); +} + +Result numeric(const Value::Object& payload, std::string_view key, double fallback) { + const auto found = payload.find(std::string(key)); + if (found == payload.end()) return Result::success(fallback); + double value = 0.0; + if (const auto* integer = found->second.getIf()) + value = static_cast(*integer); + else if (const auto* decimal = found->second.getIf()) + value = *decimal; + else + return invalid("VFX field must be numeric", std::string(key)); + if (!std::isfinite(value)) return invalid("VFX field must be finite", std::string(key)); + return Result::success(value); +} + +} // namespace + +Result ActionVfxBinding::fromPayload(const Value::Object& payload, ActionVfxShape shape) { + ActionVfxBinding candidate; + const auto uri = payload.find("uri"); + if (uri == payload.end() || !uri->second.getIf() || uri->second.getIf()->empty()) + return invalid("VFX URI must be non-empty text", "uri"); + candidate.uri = *uri->second.getIf(); + + auto spatial = ActionSpatialBinding::fromPayload(payload); + if (!spatial) return Result::failure(spatial.status()); + candidate.spatial = std::move(spatial).takeValue(); + + if (const auto stop = payload.find("stopBehavior"); stop != payload.end()) { + const auto* value = stop->second.getIf(); + if (!value) + return invalid("VFX stopBehavior must be text", "stopBehavior"); + if (*value == "stop_emitting") + candidate.stopBehavior = ActionVfxStopBehavior::StopEmitting; + else if (*value == "clear_immediately") + candidate.stopBehavior = ActionVfxStopBehavior::ClearImmediately; + else + return invalid( + "VFX stopBehavior must be stop_emitting or clear_immediately", "stopBehavior"); + } + if (const auto synced = payload.find("playbackRateSynced"); synced != payload.end()) { + const auto* value = synced->second.getIf(); + if (!value) + return invalid("VFX playbackRateSynced must be boolean", "playbackRateSynced"); + candidate.playbackRateSynced = *value; + } + + auto clipStart = numeric(payload, "clipStartTime", 0.0); + if (!clipStart) return Result::failure(clipStart.status()); + auto clipEnd = numeric(payload, "clipEndTime", 0.5); + if (!clipEnd) return Result::failure(clipEnd.status()); + if (clipStart.value() < 0.0) + return invalid("VFX clipStartTime must be non-negative", "clipStartTime"); + if (clipEnd.value() <= clipStart.value()) + return invalid("VFX clipEndTime must be greater than clipStartTime", "clipEndTime"); + candidate.clipStartTime = clipStart.value(); + candidate.clipEndTime = clipEnd.value(); + + auto lifetime = numeric(payload, "lifetimeSeconds", 0.0); + if (!lifetime) return Result::failure(lifetime.status()); + if (shape == ActionVfxShape::Instant && lifetime.value() <= 0.0) + return invalid( + "instant VFX lifetimeSeconds must be positive", "lifetimeSeconds"); + if (shape == ActionVfxShape::State && lifetime.value() < 0.0) + return invalid("VFX lifetimeSeconds must be non-negative", "lifetimeSeconds"); + candidate.lifetimeSeconds = lifetime.value(); + return Result::success(std::move(candidate)); +} + +} // namespace eve::action diff --git a/src/modules/action/ActionVfxBlock.h b/src/modules/action/ActionVfxBlock.h new file mode 100644 index 00000000..ee7784aa --- /dev/null +++ b/src/modules/action/ActionVfxBlock.h @@ -0,0 +1,42 @@ +#pragma once + +/** @file ActionVfxBlock.h @brief Typed VFX action-block payload contract. */ + +#include "action/ActionSpatialBlock.h" + +#include +#include + +namespace eve::action { + +/** @brief Authored VFX block shape used to select lifetime validation. */ +enum class ActionVfxShape : std::uint8_t { Instant, State }; + +/** @brief Behavior applied when a VFX block leaves its active interval. */ +enum class ActionVfxStopBehavior : std::uint8_t { StopEmitting, ClearImmediately }; + +/** @brief Owning validated VFX settings shared by runtime and editor preview. */ +struct ActionVfxBinding { + std::string uri; + ActionSpatialBinding spatial; + ActionVfxStopBehavior stopBehavior = ActionVfxStopBehavior::StopEmitting; + bool playbackRateSynced = true; + double clipStartTime = 0.0; + double clipEndTime = 0.5; + double lifetimeSeconds = 0.0; + + auto operator<=>(const ActionVfxBinding&) const = default; + + /** + * @brief Decode one dynamic VFX payload transactionally. + * @param payload Owning payload borrowed only for this call. + * @param shape Instant or state contract selected by the descriptor. + * @return Validated owning settings or a field-path diagnostic. + * @remarks Clip bounds must be finite with end greater than start. Instant + * blocks additionally require a positive finite lifetimeSeconds value. + */ + [[nodiscard]] static Result fromPayload(const Value::Object& payload, + ActionVfxShape shape); +}; + +} // namespace eve::action diff --git a/src/modules/action/ActionVfxDuration.h b/src/modules/action/ActionVfxDuration.h new file mode 100644 index 00000000..27a2f870 --- /dev/null +++ b/src/modules/action/ActionVfxDuration.h @@ -0,0 +1,32 @@ +#pragma once + +/** @file ActionVfxDuration.h @brief Optional VFX resource-duration query for action editors. */ + +#include "common/Result.h" +#include "common/Time.h" + +#include + +namespace eve::action { + +/** + * @brief Optional synchronous provider of authored VFX resource duration. + * + * The provider returns an owning Duration and retains no view into the URI. + * Calls are owner-thread-only and non-reentrant. The providing module must + * revoke the capability before destroying its implementation. + */ +class IActionVfxDurationProvider { +public: + static constexpr const char* capabilityName = "IActionVfxDurationProvider"; + virtual ~IActionVfxDurationProvider() = default; + + /** + * @brief Resolve the natural finite playback duration of one VFX resource. + * @param uri Borrowed resource URI valid only for this synchronous call. + * @return Finite positive duration, or a structured missing, load, or unbounded diagnostic. + */ + [[nodiscard]] virtual Result naturalDuration(std::string_view uri) const = 0; +}; + +} // namespace eve::action diff --git a/src/modules/action/editor/ActionPreviewController.cpp b/src/modules/action/editor/ActionPreviewController.cpp index 091e7e29..5af12a62 100644 --- a/src/modules/action/editor/ActionPreviewController.cpp +++ b/src/modules/action/editor/ActionPreviewController.cpp @@ -61,6 +61,9 @@ Result ActionPreviewController::buildFrame( } frame.previous = previous; frame.current = current; + auto activeBlocks = editor_.target().timeline().activeBlocks(current); + if (!activeBlocks) return Result::failure(activeBlocks.status()); + frame.activeBlocks = std::move(activeBlocks).takeValue(); frame.cues.reserve(events.size()); for (const auto& event : events) frame.cues.push_back({cueKind(event), event.itemId, event.type, event.time, event.payload}); @@ -109,6 +112,26 @@ EditorResult ActionPreviewController::seek(Duration time) { return eve::editing::applied(); } +EditorResult ActionPreviewController::stop() { + auto stopped = seek(Duration::zero()); + if (stopped.ok()) editor_.pause(); + return stopped; +} + +EditorResult ActionPreviewController::stepFrames(std::int64_t frames, double frameRate) { + auto target = editor_.frameStepTarget(frames, frameRate); + if (!target.ok()) return EditorResult::failure(target.status()); + auto stepped = seek(target.value()); + if (stepped.ok()) editor_.pause(); + return stepped; +} + +EditorResult ActionPreviewController::jumpToEnd() { + auto jumped = seek(editor_.target().timeline().duration); + if (jumped.ok()) editor_.pause(); + return jumped; +} + EditorResult ActionPreviewController::update(Duration delta) { auto plan = editor_.planPreview(delta); if (!plan.ok()) diff --git a/src/modules/action/editor/ActionPreviewController.h b/src/modules/action/editor/ActionPreviewController.h index a8e45838..56910f30 100644 --- a/src/modules/action/editor/ActionPreviewController.h +++ b/src/modules/action/editor/ActionPreviewController.h @@ -28,6 +28,12 @@ public: [[nodiscard]] EditorResult refresh(); /** @brief Atomically prepare presentation and seek the authoritative preview transport. */ [[nodiscard]] EditorResult seek(Duration time); + /** @brief Atomically stop presentation, pause transport and return to the first frame. */ + [[nodiscard]] EditorResult stop(); + /** @brief Atomically step signed frames and pause transport at the resulting cursor. */ + [[nodiscard]] EditorResult stepFrames(std::int64_t frames, double frameRate); + /** @brief Atomically seek to the final frame boundary and pause transport. */ + [[nodiscard]] EditorResult jumpToEnd(); /** @brief Atomically prepare presentation and advance by injected deterministic time. */ [[nodiscard]] EditorResult update(Duration delta); /** @brief Last frame successfully published to the host. */ diff --git a/src/modules/action/editor/ActionTimelineEditor.cpp b/src/modules/action/editor/ActionTimelineEditor.cpp index 0e323ebd..315870ee 100644 --- a/src/modules/action/editor/ActionTimelineEditor.cpp +++ b/src/modules/action/editor/ActionTimelineEditor.cpp @@ -1,8 +1,10 @@ #include "action/editor/ActionTimelineEditor.h" +#include "action/ActionParameterCurve.h" #include "editor/EditorProperty.h" #include +#include #include #include @@ -50,6 +52,21 @@ void sortAnimationSections(action::ActionTimeline& timeline) { }); } +bool containsId(const action::ActionTimeline& timeline, const LogicalId& id) { + if (std::any_of(timeline.animationSections.begin(), timeline.animationSections.end(), + [&](const auto& section) { return section.id == id; })) + return true; + for (const auto& track : timeline.tracks) { + if (track.id == id) return true; + if (std::any_of(track.notifies.begin(), track.notifies.end(), + [&](const auto& notify) { return notify.id == id; })) + return true; + if (std::any_of(track.states.begin(), track.states.end(), [&](const auto& state) { return state.id == id; })) + return true; + } + return false; +} + EditorValue replacementPayload(const std::string& json) { EditorValue::Object payload; payload["json"] = json; @@ -308,13 +325,42 @@ EditorResult ActionTimelineTarget::loadSnapshot(const EditorValue& snapsho return assign(std::move(decoded).takeValue()); } +void ActionTimelineClipboard::clear() noexcept { + items_.clear(); + track_.reset(); +} + +bool ActionTimelineClipboard::empty() const noexcept { return items_.empty() && !track_; } + ActionTimelineEditor::ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline) - : target_(std::move(targetId), std::move(timeline)), authority_(&target_), transactions_(&authority_) {} + : ActionTimelineEditor(std::move(targetId), std::move(timeline), std::make_shared()) {} + +ActionTimelineEditor::ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline, + std::shared_ptr clipboard) + : target_(std::move(targetId), std::move(timeline)), + authority_(&target_), + transactions_(&authority_), + clipboard_(clipboard ? std::move(clipboard) : std::make_shared()) {} EditorResult ActionTimelineEditor::rejected(std::string rule, std::string message) { return editorError(EditorStatus::Rejected, std::move(rule), std::move(message)); } +EditorResult ActionTimelineEditor::reloadDocument(const EditorValue& snapshot) { + if (transactions_.active()) + return rejected("editor.action.timeline.reload-active-transaction", + "Cannot reload a Montage document during an active transaction"); + auto loaded = target_.loadSnapshot(snapshot); + if (!loaded.ok()) return loaded; + transactions_.clear(); + selection_.clear(); + previewTime_ = Duration::zero(); + playing_ = false; + previewStarted_ = false; + previewEvents_.clear(); + return eve::editing::applied(); +} + EditorResult ActionTimelineEditor::configureWorkspace(EditorWorkspace& workspace) const { EditorWorkspace candidate = workspace; struct Panel { @@ -422,6 +468,40 @@ EditorResult ActionTimelineEditor::renameTrack(const LogicalId& trackId, s return commit(std::move(candidate), "Rename action track", "action.timeline.track.rename"); } +EditorResult ActionTimelineEditor::addSectionSplit(Duration time) { + if (time <= Duration::zero() || time >= target_.timeline().duration) + return rejected("editor.action.timeline.section-split-range", + "Physical section split must be strictly inside the timeline"); + action::ActionTimeline candidate = target_.timeline(); + if (std::find(candidate.splitTimestamps.begin(), candidate.splitTimestamps.end(), time) != + candidate.splitTimestamps.end()) + return rejected("editor.action.timeline.section-split-duplicate", + "Physical section split already exists at this time"); + candidate.splitTimestamps.push_back(time); + std::sort(candidate.splitTimestamps.begin(), candidate.splitTimestamps.end()); + return commit(std::move(candidate), "Add physical section split", "action.timeline.section-split.add"); +} + +EditorResult ActionTimelineEditor::setSectionSplit(std::size_t index, Duration time) { + if (index >= target_.timeline().splitTimestamps.size()) + return rejected("editor.action.timeline.section-split-not-found", "Physical section split was not found"); + if (time <= Duration::zero() || time >= target_.timeline().duration) + return rejected("editor.action.timeline.section-split-range", + "Physical section split must be strictly inside the timeline"); + action::ActionTimeline candidate = target_.timeline(); + candidate.splitTimestamps[index] = time; + std::sort(candidate.splitTimestamps.begin(), candidate.splitTimestamps.end()); + return commit(std::move(candidate), "Move physical section split", "action.timeline.section-split.move"); +} + +EditorResult ActionTimelineEditor::removeSectionSplit(std::size_t index) { + if (index >= target_.timeline().splitTimestamps.size()) + return rejected("editor.action.timeline.section-split-not-found", "Physical section split was not found"); + action::ActionTimeline candidate = target_.timeline(); + candidate.splitTimestamps.erase(candidate.splitTimestamps.begin() + static_cast(index)); + return commit(std::move(candidate), "Remove physical section split", "action.timeline.section-split.remove"); +} + EditorResult ActionTimelineEditor::addAnimationSection(action::ActionAnimationSection section) { action::ActionTimeline candidate = target_.timeline(); candidate.animationSections.push_back(std::move(section)); @@ -589,6 +669,80 @@ EditorResult ActionTimelineEditor::updateItem(const LogicalId& itemId, Log return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } +EditorResult ActionTimelineEditor::addParameterKey(const LogicalId& itemId, + action::ActionParameterKey key) { + for (const auto& track : target_.timeline().tracks) { + const auto found = std::find_if(track.states.begin(), track.states.end(), + [&](const auto& state) { return state.id == itemId; }); + if (found == track.states.end()) continue; + if (found->type.format() != "presentation:parameter-curve") + return rejected("editor.action.timeline.parameter-type", "Timeline item is not a parameter curve"); + auto binding = action::ActionParameterCurveBinding::fromPayload(found->payload); + if (!binding) + return rejected("editor.action.timeline.parameter-invalid", commonMessage(binding.status(), + "Parameter curve is invalid")); + binding.value().keys.push_back(key); + std::sort(binding.value().keys.begin(), binding.value().keys.end(), + [](const auto& left, const auto& right) { return left.time < right.time; }); + auto payload = binding.value().toPayload(found->payload); + auto validated = action::ActionParameterCurveBinding::fromPayload(payload); + if (!validated) + return rejected("editor.action.timeline.parameter-key-invalid", + commonMessage(validated.status(), "Parameter key is invalid")); + return updateItem(itemId, found->type, std::move(payload)); + } + return rejected("editor.action.timeline.state-not-found", "Parameter curve was not found"); +} + +EditorResult ActionTimelineEditor::editParameterKey(const LogicalId& itemId, std::size_t index, + action::ActionParameterKey key) { + for (const auto& track : target_.timeline().tracks) { + const auto found = std::find_if(track.states.begin(), track.states.end(), + [&](const auto& state) { return state.id == itemId; }); + if (found == track.states.end()) continue; + if (found->type.format() != "presentation:parameter-curve") + return rejected("editor.action.timeline.parameter-type", "Timeline item is not a parameter curve"); + auto binding = action::ActionParameterCurveBinding::fromPayload(found->payload); + if (!binding) + return rejected("editor.action.timeline.parameter-invalid", commonMessage(binding.status(), + "Parameter curve is invalid")); + if (index >= binding.value().keys.size()) + return rejected("editor.action.timeline.parameter-key-not-found", "Parameter key index is unavailable"); + binding.value().keys[index] = key; + auto payload = binding.value().toPayload(found->payload); + auto validated = action::ActionParameterCurveBinding::fromPayload(payload); + if (!validated) + return rejected("editor.action.timeline.parameter-key-invalid", + commonMessage(validated.status(), "Parameter key is invalid")); + return updateItem(itemId, found->type, std::move(payload)); + } + return rejected("editor.action.timeline.state-not-found", "Parameter curve was not found"); +} + +EditorResult ActionTimelineEditor::removeParameterKey(const LogicalId& itemId, std::size_t index) { + for (const auto& track : target_.timeline().tracks) { + const auto found = std::find_if(track.states.begin(), track.states.end(), + [&](const auto& state) { return state.id == itemId; }); + if (found == track.states.end()) continue; + if (found->type.format() != "presentation:parameter-curve") + return rejected("editor.action.timeline.parameter-type", "Timeline item is not a parameter curve"); + auto binding = action::ActionParameterCurveBinding::fromPayload(found->payload); + if (!binding) + return rejected("editor.action.timeline.parameter-invalid", commonMessage(binding.status(), + "Parameter curve is invalid")); + if (index == 0 || index + 1 >= binding.value().keys.size()) + return rejected("editor.action.timeline.parameter-endpoint", "Parameter curve endpoints cannot be removed"); + binding.value().keys.erase(binding.value().keys.begin() + static_cast(index)); + auto payload = binding.value().toPayload(found->payload); + auto validated = action::ActionParameterCurveBinding::fromPayload(payload); + if (!validated) + return rejected("editor.action.timeline.parameter-key-invalid", + commonMessage(validated.status(), "Parameter key removal is invalid")); + return updateItem(itemId, found->type, std::move(payload)); + } + return rejected("editor.action.timeline.state-not-found", "Parameter curve was not found"); +} + EditorResult ActionTimelineEditor::editItem(const LogicalId& itemId, Duration start, Duration end, LogicalId type, Value::Object payload) { if (!type.isValid()) return rejected("editor.action.timeline.type-invalid", "Timeline item type is invalid"); @@ -652,6 +806,29 @@ EditorResult ActionTimelineEditor::removeItem(const LogicalId& itemId) { return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); } +EditorResult ActionTimelineEditor::setItemEnabled(const LogicalId& itemId, bool enabled) { + action::ActionTimeline candidate = target_.timeline(); + for (auto& track : candidate.tracks) { + for (auto& notify : track.notifies) { + if (notify.id != itemId) continue; + if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); + if (notify.enabled == enabled) return eve::editing::noOp(); + notify.enabled = enabled; + return commit(std::move(candidate), enabled ? "Enable action notify" : "Disable action notify", + "action.timeline.item.enabled"); + } + for (auto& state : track.states) { + if (state.id != itemId) continue; + if (track.locked) return rejected("editor.action.timeline.track-locked", "Action track is locked"); + if (state.enabled == enabled) return eve::editing::noOp(); + state.enabled = enabled; + return commit(std::move(candidate), enabled ? "Enable action notify state" : "Disable action notify state", + "action.timeline.item.enabled"); + } + } + return rejected("editor.action.timeline.item-not-found", "Timeline item was not found"); +} + EditorResult ActionTimelineEditor::setTrackMuted(const LogicalId& trackId, bool muted) { action::ActionTimeline candidate = target_.timeline(); auto* track = findTrack(candidate, trackId); @@ -713,38 +890,47 @@ std::vector ActionTimelineEditor::selectedItemIds() const { } EditorResult ActionTimelineEditor::copySelection() { - clipboard_.clear(); + clipboard_->clear(); for (const auto& section : target_.timeline().animationSections) if (selection_.contains(section.id.format())) - clipboard_.push_back({ClipboardItem::Kind::AnimationSection, {}, section, {}, {}}); + clipboard_->items_.push_back({ActionTimelineClipboard::Item::Kind::AnimationSection, {}, section, {}, {}}); for (const auto& track : target_.timeline().tracks) { for (const auto& notify : track.notifies) if (selection_.contains(notify.id.format())) - clipboard_.push_back({ClipboardItem::Kind::Notify, track.id, {}, notify, {}}); + clipboard_->items_.push_back({ActionTimelineClipboard::Item::Kind::Notify, track.id, {}, notify, {}}); for (const auto& state : track.states) if (selection_.contains(state.id.format())) - clipboard_.push_back({ClipboardItem::Kind::NotifyState, track.id, {}, {}, state}); + clipboard_->items_.push_back( + {ActionTimelineClipboard::Item::Kind::NotifyState, track.id, {}, {}, state}); } - return eve::editing::applied(clipboard_.size()); + return eve::editing::applied(clipboard_->items_.size()); } -LogicalId ActionTimelineEditor::copiedId(const LogicalId& source) { - const std::string name = std::string(source.name()) + ".copy." + std::to_string(++copySequence_); - auto id = LogicalId::fromParts(source.namespaceName(), name); - return id ? std::move(*id) : LogicalId{}; +LogicalId ActionTimelineEditor::copiedId(const LogicalId& source, const action::ActionTimeline& candidate) { + while (true) { + const std::string name = std::string(source.name()) + ".copy." + std::to_string(++copySequence_); + auto id = LogicalId::fromParts(source.namespaceName(), name); + if (id && !containsId(candidate, *id)) return std::move(*id); + } } -EditorResult ActionTimelineEditor::paste(Duration offset) { - if (clipboard_.empty()) +EditorResult ActionTimelineEditor::paste(Duration offset) { return pasteItems(offset, nullptr); } + +EditorResult ActionTimelineEditor::pasteToTrack(const LogicalId& trackId, Duration offset) { + return pasteItems(offset, &trackId); +} + +EditorResult ActionTimelineEditor::pasteItems(Duration offset, const LogicalId* destinationTrack) { + if (clipboard_->items_.empty()) return eve::editing::failed(EditorStatus::Rejected, RuleId("editor.action.timeline.clipboard-empty"), - "Action timeline clipboard is empty"); + "Action timeline item clipboard is empty"); action::ActionTimeline candidate = target_.timeline(); std::set pastedSelection; - for (const auto& item : clipboard_) { - if (item.kind == ClipboardItem::Kind::AnimationSection) { + for (const auto& item : clipboard_->items_) { + if (item.kind == ActionTimelineClipboard::Item::Kind::AnimationSection) { auto copy = item.animationSection; - copy.id = copiedId(copy.id); + copy.id = copiedId(copy.id, candidate); auto start = copy.start.tryAdd(offset); auto end = copy.end.tryAdd(offset); if (!start || !end) @@ -759,17 +945,17 @@ EditorResult ActionTimelineEditor::paste(Duration offset) { [](const auto& left, const auto& right) { return left.start < right.start; }); continue; } - auto* track = findTrack(candidate, item.trackId); + auto* track = findTrack(candidate, destinationTrack ? *destinationTrack : item.trackId); if (!track) return eve::editing::failed(EditorStatus::Conflict, RuleId("editor.action.timeline.track-not-found"), - "Clipboard track no longer exists"); + "Paste target track does not exist"); if (track->locked) return eve::editing::failed( EditorStatus::Rejected, RuleId("editor.action.timeline.track-locked"), "Clipboard track is locked"); - if (item.kind == ClipboardItem::Kind::NotifyState) { + if (item.kind == ActionTimelineClipboard::Item::Kind::NotifyState) { auto copy = item.notifyState; - copy.id = copiedId(copy.id); + copy.id = copiedId(copy.id, candidate); auto start = copy.start.tryAdd(offset); auto end = copy.end.tryAdd(offset); if (!start || !end) @@ -782,7 +968,7 @@ EditorResult ActionTimelineEditor::paste(Duration offset) { track->states.push_back(std::move(copy)); } else { auto copy = item.notify; - copy.id = copiedId(copy.id); + copy.id = copiedId(copy.id, candidate); auto time = copy.time.tryAdd(offset); if (!time) return eve::editing::failed(EditorStatus::Rejected, @@ -803,6 +989,35 @@ EditorResult ActionTimelineEditor::paste(Duration offset) { return eve::editing::applied(count); } +EditorResult ActionTimelineEditor::copyTrack(const LogicalId& trackId) { + const auto* track = findTrack(target_.timeline(), trackId); + if (!track) return rejected("editor.action.timeline.track-not-found", "Action track was not found"); + clipboard_->clear(); + clipboard_->track_ = *track; + return eve::editing::applied(); +} + +EditorResult ActionTimelineEditor::pasteTrack() { + if (!clipboard_->track_) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.track-clipboard-empty"), + "Action timeline track clipboard is empty"); + action::ActionTimeline candidate = target_.timeline(); + auto track = *clipboard_->track_; + track.id = copiedId(track.id, candidate); + track.label += " Copy"; + const LogicalId pastedId = track.id; + candidate.tracks.push_back(std::move(track)); + auto& pasted = candidate.tracks.back(); + for (auto& notify : pasted.notifies) notify.id = copiedId(notify.id, candidate); + for (auto& state : pasted.states) state.id = copiedId(state.id, candidate); + sortTrack(pasted); + auto committed = commit(std::move(candidate), "Paste action timeline track", "action.timeline.track.paste"); + if (!committed.ok()) return EditorResult::failure(committed.status()); + selection_.clear(); + return eve::editing::applied(pastedId); +} + EditorResult ActionTimelineEditor::deleteSelection() { if (selection_.empty()) return rejected("editor.action.timeline.selection-empty", "No timeline items are selected"); action::ActionTimeline candidate = target_.timeline(); @@ -828,6 +1043,43 @@ EditorResult ActionTimelineEditor::undo() { return transacti EditorResult ActionTimelineEditor::redo() { return transactions_.redo(); } +EditorResult ActionTimelineEditor::stop() { + auto stopped = seek(Duration::zero()); + if (stopped.ok()) playing_ = false; + return stopped; +} + +EditorResult ActionTimelineEditor::frameStepTarget(std::int64_t frames, double frameRate) const { + if (!std::isfinite(frameRate) || frameRate <= 0.0) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.frame-rate"), + "Preview frame rate must be finite and positive"); + const long double seconds = static_cast(previewTime_.nanoseconds()) / 1'000'000'000.0L + + static_cast(frames) / static_cast(frameRate); + const long double maximum = static_cast(target_.timeline().duration.nanoseconds()) / + 1'000'000'000.0L; + auto target = Duration::fromSeconds(static_cast(std::clamp(seconds, 0.0L, maximum))); + if (!target) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.frame-step-overflow"), + "Preview frame step is outside the duration domain"); + return eve::editing::applied(std::move(target).takeValue()); +} + +EditorResult ActionTimelineEditor::stepFrames(std::int64_t frames, double frameRate) { + auto target = frameStepTarget(frames, frameRate); + if (!target.ok()) return EditorResult::failure(target.status()); + auto stepped = seek(target.value()); + if (stepped.ok()) playing_ = false; + return stepped; +} + +EditorResult ActionTimelineEditor::jumpToEnd() { + auto jumped = seek(target_.timeline().duration); + if (jumped.ok()) playing_ = false; + return jumped; +} + EditorResult ActionTimelineEditor::seek(Duration time) { if (time < Duration::zero() || time > target_.timeline().duration) return rejected("editor.action.timeline.seek-range", "Preview seek is outside the timeline"); @@ -901,4 +1153,165 @@ EditorResult ActionTimelineEditor::applyPreviewPlan(ActionTimelineP return eve::editing::applied(previewEvents_.size()); } +ActionTimelineDocumentWorkspace::ActionTimelineDocumentWorkspace(DocumentService& documents) : documents_(&documents) {} + +EditorResult ActionTimelineDocumentWorkspace::decode(const EditorValue& content) { + auto timeline = action::ActionTimeline::fromValue(toPresentationValue(content)); + if (!timeline) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.document.invalid-content"), + commonMessage(timeline.status(), + "Montage document content is invalid")); + return eve::editing::applied(std::move(timeline).takeValue()); +} + +ActionTimelineDocumentWorkspace::Tab* ActionTimelineDocumentWorkspace::find(const DocumentId& document) { + const auto found = std::find_if(tabs_.begin(), tabs_.end(), + [&](const Tab& tab) { return tab.document == document; }); + return found == tabs_.end() ? nullptr : &*found; +} + +const ActionTimelineDocumentWorkspace::Tab* ActionTimelineDocumentWorkspace::find(const DocumentId& document) const { + const auto found = std::find_if(tabs_.begin(), tabs_.end(), + [&](const Tab& tab) { return tab.document == document; }); + return found == tabs_.end() ? nullptr : &*found; +} + +EditorResult ActionTimelineDocumentWorkspace::open(DocumentKey key, std::string title, + std::string resourceUri, + action::ActionTimeline initialTimeline) { + if (key.kind != DocumentKind::Timeline) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.document.kind"), + "Montage workspace only opens Timeline documents"); + auto valid = initialTimeline.validate(); + if (!valid) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.document.initial-invalid"), + commonMessage(valid.status(), "Initial Montage timeline is invalid")); + auto initial = initialTimeline.toValue(); + if (!initial) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.document.initial-encode"), + "Initial Montage timeline could not be encoded"); + auto opened = documents_->open(std::move(key), std::move(title), std::move(resourceUri), + toEditorValue(initial.value())); + if (!opened.ok()) return EditorResult::failure(opened.status()); + if (find(opened.value().id)) { + activeDocument_ = opened.value().id; + return eve::editing::applied(activeDocument_); + } + auto content = documents_->content(opened.value().id); + if (!content.ok()) return EditorResult::failure(content.status()); + auto timeline = decode(content.value()); + if (!timeline.ok()) return EditorResult::failure(timeline.status()); + + Tab tab; + tab.document = opened.value().id; + tab.editor = std::make_unique(tab.document.value(), std::move(timeline).takeValue(), clipboard_); + tab.synchronizedEditorRevision = tab.editor->target().revision(); + tab.savedEditorRevision = tab.editor->target().revision(); + activeDocument_ = tab.document; + tabs_.push_back(std::move(tab)); + return eve::editing::applied(activeDocument_); +} + +EditorResult ActionTimelineDocumentWorkspace::activate(const DocumentId& document) { + if (!find(document)) + return eve::editing::failed(EditorStatus::NotFound, RuleId("editor.action.document.not-open"), + "Montage document tab is not open"); + activeDocument_ = document; + return eve::editing::applied(); +} + +EditorResult ActionTimelineDocumentWorkspace::synchronize(Tab& tab) { + auto snapshot = documents_->snapshot(tab.document); + if (!snapshot.ok()) return snapshot; + if (tab.editor->target().revision() == tab.synchronizedEditorRevision) return snapshot; + auto edited = documents_->edit(tab.document, tab.editor->target().snapshotValue(), snapshot.value().revision.edit); + if (edited.ok()) tab.synchronizedEditorRevision = tab.editor->target().revision(); + return edited; +} + +EditorResult ActionTimelineDocumentWorkspace::save(const DocumentId& document) { + auto* tab = find(document); + if (!tab) + return eve::editing::failed(EditorStatus::NotFound, + RuleId("editor.action.document.not-open"), + "Montage document tab is not open"); + auto synchronized = synchronize(*tab); + if (!synchronized.ok()) return synchronized; + auto ticket = documents_->requestSave(document); + if (!ticket.ok()) return EditorResult::failure(ticket.status()); + auto saved = documents_->executeSave(ticket.value()); + if (saved.ok()) tab->savedEditorRevision = tab->editor->target().revision(); + return saved; +} + +EditorResult ActionTimelineDocumentWorkspace::reconcile(const DocumentId& document) { + auto* tab = find(document); + if (!tab) + return eve::editing::failed(EditorStatus::NotFound, + RuleId("editor.action.document.not-open"), + "Montage document tab is not open"); + auto synchronized = synchronize(*tab); + if (!synchronized.ok()) return synchronized; + auto reconciled = documents_->reconcileExternal(document, [](const EditorValue& content) { + auto timeline = decode(content); + if (!timeline.ok()) return EditorResult::failure(timeline.status()); + return eve::editing::applied(); + }); + if (!reconciled.ok()) return reconciled; + auto content = documents_->content(document); + if (!content.ok()) return EditorResult::failure(content.status()); + if (content.value() == tab->editor->target().snapshotValue()) return reconciled; + auto loaded = tab->editor->reloadDocument(content.value()); + if (!loaded.ok()) return EditorResult::failure(loaded.status()); + tab->synchronizedEditorRevision = tab->editor->target().revision(); + tab->savedEditorRevision = tab->editor->target().revision(); + return reconciled; +} + +EditorResult ActionTimelineDocumentWorkspace::close(const DocumentId& document, + ActionTimelineCloseMode mode) { + const auto found = std::find_if(tabs_.begin(), tabs_.end(), + [&](const Tab& tab) { return tab.document == document; }); + if (found == tabs_.end()) + return eve::editing::failed(EditorStatus::NotFound, RuleId("editor.action.document.not-open"), + "Montage document tab is not open"); + auto snapshot = documents_->snapshot(document); + if (!snapshot.ok()) return EditorResult::failure(snapshot.status()); + const bool dirty = found->editor->target().revision() != found->savedEditorRevision || snapshot.value().dirty(); + if (dirty && mode == ActionTimelineCloseMode::ProtectDirty) + return eve::editing::failed(EditorStatus::Conflict, RuleId("editor.action.document.unsaved"), + "Montage document has unsaved edits"); + auto closed = documents_->close(document); + if (!closed.ok()) return closed; + const std::size_t index = static_cast(std::distance(tabs_.begin(), found)); + tabs_.erase(found); + if (activeDocument_ == document) { + activeDocument_ = tabs_.empty() ? DocumentId{} : tabs_[std::min(index, tabs_.size() - 1)].document; + } + return eve::editing::applied(); +} + +EditorResult> ActionTimelineDocumentWorkspace::tabs() const { + std::vector result; + result.reserve(tabs_.size()); + for (const auto& tab : tabs_) { + auto snapshot = documents_->snapshot(tab.document); + if (!snapshot.ok()) + return EditorResult>::failure(snapshot.status()); + result.push_back({snapshot.value(), tab.document == activeDocument_, + snapshot.value().dirty() || + tab.editor->target().revision() != tab.savedEditorRevision}); + } + return eve::editing::applied>(std::move(result)); +} + +ActionTimelineEditor* ActionTimelineDocumentWorkspace::activeEditor() noexcept { + auto* tab = find(activeDocument_); + return tab ? tab->editor.get() : nullptr; +} + } // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelineEditor.h b/src/modules/action/editor/ActionTimelineEditor.h index f4e5a1c8..99d5e5d3 100644 --- a/src/modules/action/editor/ActionTimelineEditor.h +++ b/src/modules/action/editor/ActionTimelineEditor.h @@ -6,18 +6,28 @@ */ #include "action/ActionTimeline.h" +#include "action/ActionParameterCurve.h" #include "editor/EditorAuthority.h" +#include "editor/EditorDocumentService.h" #include "editor/EditorProperty.h" #include "editor/EditorSelection.h" #include "editor/EditorTransactionService.h" #include "editor/EditorWorkspace.h" +#include +#include #include #include #include namespace eve::editor { +/** @brief Exact outer time bounds of the current owning timeline selection. */ +struct TimelineSelectionRange { + Duration start = Duration::zero(); + Duration end = Duration::zero(); +}; + /** @brief Prepared deterministic preview advance that can be validated before presentation side effects. */ struct ActionTimelinePreviewPlan { unsigned long long timelineRevision = 0; @@ -28,6 +38,36 @@ struct ActionTimelinePreviewPlan { std::vector events; }; +/** + * @brief Owning clipboard shared explicitly by one or more action timeline editors. + * + * The clipboard contains deep value copies only. It has no target or UI lifetime + * dependency and may therefore be shared by multiple open documents on one owner + * thread. Concurrent access is not supported. + */ +class ActionTimelineClipboard { +public: + /** @brief Remove every copied item and track. */ + void clear() noexcept; + /** @brief Whether neither item nor track content is available. */ + bool empty() const noexcept; + +private: + friend class ActionTimelineEditor; + + struct Item { + enum class Kind : std::uint8_t { AnimationSection, Notify, NotifyState }; + Kind kind = Kind::Notify; + LogicalId trackId; + action::ActionAnimationSection animationSection; + action::ActionNotify notify; + action::ActionNotifyState notifyState; + }; + + std::vector items_; + std::optional track_; +}; + /** * @brief Authoritative editor target for one action timeline asset. * @@ -114,9 +154,19 @@ class ActionTimelineEditor { public: /** @brief Construct a self-contained editor for one canonical timeline. */ ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline); + /** + * @brief Construct an editor using an explicitly shared owning clipboard. + * @param targetId Stable editor target identity. + * @param timeline Valid canonical timeline owned by this editor. + * @param clipboard Clipboard whose shared ownership is retained; null creates an isolated clipboard. + */ + ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline, + std::shared_ptr clipboard); /** @brief Borrow the authoritative target. */ const ActionTimelineTarget& target() const noexcept { return target_; } + /** @brief Atomically replace the document snapshot and clear history, selection and preview transport. */ + [[nodiscard]] EditorResult reloadDocument(const EditorValue& snapshot); /** @brief Atomically install the standard asset/preview/timeline/inspector panel composition. */ [[nodiscard]] EditorResult configureWorkspace(EditorWorkspace& workspace) const; /** @brief Add an empty semantic track. */ @@ -125,6 +175,14 @@ public: [[nodiscard]] EditorResult removeTrack(const LogicalId& trackId); /** @brief Rename one track without changing its stable identity. */ [[nodiscard]] EditorResult renameTrack(const LogicalId& trackId, std::string label); + /** @brief Replace montage-wide playback, blending, layer and root-motion settings in one undo step. */ + [[nodiscard]] EditorResult setMontageSettings(action::ActionMontageSettings settings); + /** @brief Insert one physical-section split strictly inside the montage duration. */ + [[nodiscard]] EditorResult addSectionSplit(Duration time); + /** @brief Move one indexed physical-section split while preserving strict ordering. */ + [[nodiscard]] EditorResult setSectionSplit(std::size_t index, Duration time); + /** @brief Remove one indexed physical-section split. */ + [[nodiscard]] EditorResult removeSectionSplit(std::size_t index); /** @brief Add one objective animation section to the montage lane. */ [[nodiscard]] EditorResult addAnimationSection(action::ActionAnimationSection section); /** @brief Move an animation section while preserving its duration. */ @@ -153,9 +211,18 @@ public: [[nodiscard]] EditorResult resizeState(const LogicalId& itemId, Duration start, Duration end); /** @brief Replace one item's type and owning payload through one undoable transaction. */ [[nodiscard]] EditorResult updateItem(const LogicalId& itemId, LogicalId type, Value::Object payload); + /** @brief Insert one normalized key into a parameter curve as an undoable edit. */ + [[nodiscard]] EditorResult addParameterKey(const LogicalId& itemId, action::ActionParameterKey key); + /** @brief Replace one indexed parameter key as an undoable edit. */ + [[nodiscard]] EditorResult editParameterKey(const LogicalId& itemId, std::size_t index, + action::ActionParameterKey key); + /** @brief Remove one non-endpoint parameter key as an undoable edit. */ + [[nodiscard]] EditorResult removeParameterKey(const LogicalId& itemId, std::size_t index); /** @brief Atomically replace one item's timing, type and owning payload. */ [[nodiscard]] EditorResult editItem(const LogicalId& itemId, Duration start, Duration end, LogicalId type, Value::Object payload); + /** @brief Enable or disable one notify/state through an undoable transaction. */ + [[nodiscard]] EditorResult setItemEnabled(const LogicalId& itemId, bool enabled); /** @brief Remove one notify or state. */ [[nodiscard]] EditorResult removeItem(const LogicalId& itemId); /** @brief Mute/unmute one track through an undoable operation. */ @@ -173,10 +240,29 @@ public: std::size_t selectionCount() const noexcept { return selection_.size(); } /** @brief Return selected item IDs in lexical order as owning values. */ [[nodiscard]] std::vector selectedItemIds() const; + /** @brief Return the outer bounds of every selected section, notify and notify-state. */ + [[nodiscard]] EditorResult selectionRange() const; + /** @brief Move the complete selection by one exact delta in a single undoable transaction. */ + [[nodiscard]] EditorResult moveSelection(Duration delta); + /** @brief Align selected item starts to the earliest selected start while preserving item durations. */ + [[nodiscard]] EditorResult alignSelectionStart(); + /** @brief Align selected item ends to the latest selected end while preserving item durations. */ + [[nodiscard]] EditorResult alignSelectionEnd(); + /** + * @brief Proportionally fit the complete selection into exact destination bounds. + * @remarks Relative times and section blend windows are rounded to the nearest nanosecond. + */ + [[nodiscard]] EditorResult scaleSelection(Duration start, Duration end); /** @brief Copy selected items into an owning editor clipboard. */ [[nodiscard]] EditorResult copySelection(); /** @brief Paste copied items at an exact offset with new stable ids. */ [[nodiscard]] EditorResult paste(Duration offset); + /** @brief Paste copied notify items into a chosen target track for cross-document workflows. */ + [[nodiscard]] EditorResult pasteToTrack(const LogicalId& trackId, Duration offset); + /** @brief Deep-copy one complete track into the shared clipboard. */ + [[nodiscard]] EditorResult copyTrack(const LogicalId& trackId); + /** @brief Paste a copied complete track with collision-free track and item ids. */ + [[nodiscard]] EditorResult pasteTrack(); /** @brief Delete every selected item as one transaction. */ [[nodiscard]] EditorResult deleteSelection(); @@ -193,6 +279,18 @@ public: void play() noexcept { playing_ = true; } /** @brief Pause preview playback. */ void pause() noexcept { playing_ = false; } + /** @brief Pause and return the preview cursor to the first frame. */ + [[nodiscard]] EditorResult stop(); + /** + * @brief Resolve a signed frame step without mutating transport state. + * @param frames Signed number of frames; negative values step backward. + * @param frameRate Finite positive frames per second used for deterministic rounding. + */ + [[nodiscard]] EditorResult frameStepTarget(std::int64_t frames, double frameRate) const; + /** @brief Pause and move by a signed number of frames, clamped to the timeline. */ + [[nodiscard]] EditorResult stepFrames(std::int64_t frames, double frameRate); + /** @brief Pause and move the cursor to the final frame boundary. */ + [[nodiscard]] EditorResult jumpToEnd(); /** @brief Seek preview without emitting crossed events. */ [[nodiscard]] EditorResult seek(Duration time); /** @brief Advance preview by injected time and collect crossed events. */ @@ -209,24 +307,16 @@ public: const std::vector& previewEvents() const noexcept { return previewEvents_; } private: - struct ClipboardItem { - enum class Kind : std::uint8_t { AnimationSection, Notify, NotifyState }; - Kind kind = Kind::Notify; - LogicalId trackId; - action::ActionAnimationSection animationSection; - action::ActionNotify notify; - action::ActionNotifyState notifyState; - }; - [[nodiscard]] EditorResult commit(action::ActionTimeline candidate, std::string label, std::string mergeKey); [[nodiscard]] static EditorResult rejected(std::string rule, std::string message); - [[nodiscard]] LogicalId copiedId(const LogicalId& source); + [[nodiscard]] LogicalId copiedId(const LogicalId& source, const action::ActionTimeline& candidate); + [[nodiscard]] EditorResult pasteItems(Duration offset, const LogicalId* destinationTrack); ActionTimelineTarget target_; LocalWorldAuthority authority_; LocalTransactionBackend transactions_; std::set selection_; - std::vector clipboard_; + std::shared_ptr clipboard_; std::uint64_t transactionSequence_ = 0; std::uint64_t copySequence_ = 0; Duration previewTime_ = Duration::zero(); @@ -235,4 +325,75 @@ private: std::vector previewEvents_; }; +/** @brief Dirty-document policy applied when closing one Montage tab. */ +enum class ActionTimelineCloseMode : std::uint8_t { ProtectDirty, Discard }; + +/** @brief Immutable projection of one open Montage document tab. */ +struct ActionTimelineTabSnapshot { + DocumentSnapshot document; + bool active = false; + bool dirty = false; +}; + +/** + * @brief Multi-document Montage workspace backed by the engine document service. + * + * This owner-thread-only coordinator owns tab editors and one shared deep-copy + * clipboard. The injected DocumentService remains the persistence authority and + * must outlive the workspace. Each save publishes the editor's canonical target + * snapshot to DocumentService before requesting its revision-qualified CAS save. + */ +class ActionTimelineDocumentWorkspace { +public: + /** @brief Bind a non-owning document service that outlives this workspace. */ + explicit ActionTimelineDocumentWorkspace(DocumentService& documents); + + /** + * @brief Open or activate one Montage asset document. + * @param key Stable asset key; Timeline is the expected document kind. + * @param title User-visible tab title. + * @param resourceUri DocumentService resource URI used for persistence. + * @param initialTimeline Initial value used only when the store has no document. + */ + [[nodiscard]] EditorResult open(DocumentKey key, std::string title, std::string resourceUri, + action::ActionTimeline initialTimeline); + /** @brief Activate an already-open tab. */ + [[nodiscard]] EditorResult activate(const DocumentId& document); + /** @brief Save one exact canonical editor revision through DocumentService CAS persistence. */ + [[nodiscard]] EditorResult save(const DocumentId& document); + /** @brief Reconcile a tab with external persistence, rejecting dirty conflicts without data loss. */ + [[nodiscard]] EditorResult reconcile(const DocumentId& document); + /** @brief Close a tab, rejecting unsaved work unless explicit discard is requested. */ + [[nodiscard]] EditorResult close(const DocumentId& document, ActionTimelineCloseMode mode); + + /** @brief Ordered immutable tab projections for rendering a document bar. */ + [[nodiscard]] EditorResult> tabs() const; + /** + * @brief Borrow the active editor. + * @return Pointer owned by this workspace, invalidated by closing that tab. + * @lifetime Valid until close() removes the active document tab. + */ + ActionTimelineEditor* activeEditor() noexcept; + /** @brief Stable active document identity, or empty when no tab is open. */ + const DocumentId& activeDocument() const noexcept { return activeDocument_; } + +private: + struct Tab { + DocumentId document; + std::unique_ptr editor; + std::uint64_t synchronizedEditorRevision = 0; + std::uint64_t savedEditorRevision = 0; + }; + + Tab* find(const DocumentId& document); + const Tab* find(const DocumentId& document) const; + [[nodiscard]] EditorResult synchronize(Tab& tab); + [[nodiscard]] static EditorResult decode(const EditorValue& content); + + DocumentService* documents_ = nullptr; + std::shared_ptr clipboard_ = std::make_shared(); + std::vector tabs_; + DocumentId activeDocument_; +}; + } // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelinePayloadEditor.cpp b/src/modules/action/editor/ActionTimelinePayloadEditor.cpp new file mode 100644 index 00000000..09d12dc4 --- /dev/null +++ b/src/modules/action/editor/ActionTimelinePayloadEditor.cpp @@ -0,0 +1,130 @@ +#include "action/editor/ActionTimelinePayloadEditor.h" + +#include "action/ActionVfxBlock.h" + +#include +#include +#include +#include + +namespace eve::editor { +namespace { + +template +EditorResult payloadError(std::string rule, std::string message) { + return eve::editing::failed(EditorStatus::Rejected, RuleId(std::move(rule)), std::move(message)); +} + +struct PayloadItem { + LogicalId trackId; + LogicalId type; + action::ActionTimelineEventKind kind = action::ActionTimelineEventKind::Notify; + Duration time; + Duration end; + Value::Object payload; +}; + +std::optional findPayloadItem(const action::ActionTimeline& timeline, const LogicalId& itemId) { + for (const auto& track : timeline.tracks) { + const auto notify = std::find_if(track.notifies.begin(), track.notifies.end(), + [&](const auto& value) { return value.id == itemId; }); + if (notify != track.notifies.end()) + return PayloadItem{track.id, notify->type, action::ActionTimelineEventKind::Notify, notify->time, + notify->time, notify->payload}; + const auto state = std::find_if(track.states.begin(), track.states.end(), + [&](const auto& value) { return value.id == itemId; }); + if (state != track.states.end()) + return PayloadItem{track.id, state->type, action::ActionTimelineEventKind::StateEnter, state->start, + state->end, state->payload}; + } + return std::nullopt; +} + +} // namespace + +EditorResult ActionTimelinePayloadEditor::payload(const LogicalId& itemId) const { + const auto item = findPayloadItem(editor_.target().timeline(), itemId); + if (!item) + return payloadError("editor.action.timeline.payload-item-missing", + "Editable action-block payload was not found"); + return eve::editing::applied(item->payload); +} + +EditorResult ActionTimelinePayloadEditor::patch(const LogicalId& itemId, Value::Object fields) { + const auto item = findPayloadItem(editor_.target().timeline(), itemId); + if (!item) + return payloadError("editor.action.timeline.payload-item-missing", + "Editable action-block payload was not found"); + auto merged = item->payload; + for (auto& [field, value] : fields) merged.insert_or_assign(std::move(field), std::move(value)); + action::ActionTimelineEvent event{item->kind, item->trackId, itemId, item->type, item->time, merged}; + auto valid = registry_.validate(event); + if (!valid) return EditorResult::failure(valid.status()); + return editor_.updateItem(itemId, item->type, std::move(merged)); +} + +EditorResult ActionTimelinePayloadEditor::fitBlockToClip(const LogicalId& itemId) { + const auto item = findPayloadItem(editor_.target().timeline(), itemId); + if (!item) + return payloadError("editor.action.timeline.payload-item-missing", + "Editable action-block payload was not found"); + if (item->kind != action::ActionTimelineEventKind::StateEnter || + item->type.format() != "presentation:vfx-state") + return payloadError("editor.action.timeline.clip-fit-type", "Only VFX states can fit a block to a clip"); + auto binding = action::ActionVfxBinding::fromPayload(item->payload, action::ActionVfxShape::State); + if (!binding) return EditorResult::failure(binding.status()); + auto duration = Duration::fromSeconds(binding.value().clipEndTime - binding.value().clipStartTime); + if (!duration) return EditorResult::failure(duration.status()); + auto end = item->time.tryAdd(duration.value()); + if (!end) + return payloadError("editor.action.timeline.clip-fit-overflow", "Fitted VFX block time overflowed"); + return editor_.editItem(itemId, item->time, end.value(), item->type, item->payload); +} + +EditorResult ActionTimelinePayloadEditor::fitClipToBlock(const LogicalId& itemId) { + const auto item = findPayloadItem(editor_.target().timeline(), itemId); + if (!item) + return payloadError("editor.action.timeline.payload-item-missing", + "Editable action-block payload was not found"); + if (item->kind != action::ActionTimelineEventKind::StateEnter || + item->type.format() != "presentation:vfx-state") + return payloadError("editor.action.timeline.clip-fit-type", "Only VFX states can fit a clip to a block"); + auto binding = action::ActionVfxBinding::fromPayload(item->payload, action::ActionVfxShape::State); + if (!binding) return EditorResult::failure(binding.status()); + const double blockDuration = + Duration::fromNanoseconds(item->end.nanoseconds() - item->time.nanoseconds()).seconds(); + auto merged = item->payload; + merged.insert_or_assign("clipEndTime", binding.value().clipStartTime + blockDuration); + action::ActionTimelineEvent event{item->kind, item->trackId, itemId, item->type, item->time, merged}; + auto valid = registry_.validate(event); + if (!valid) return EditorResult::failure(valid.status()); + return editor_.editItem(itemId, item->time, item->end, item->type, std::move(merged)); +} + +EditorResult ActionTimelinePayloadEditor::fitClipToNaturalDuration( + const LogicalId& itemId, OptionalRef provider) { + const auto item = findPayloadItem(editor_.target().timeline(), itemId); + if (!item) + return payloadError("editor.action.timeline.payload-item-missing", + "Editable action-block payload was not found"); + if (item->kind != action::ActionTimelineEventKind::StateEnter || + item->type.format() != "presentation:vfx-state") + return payloadError("editor.action.timeline.clip-fit-type", + "Only VFX states can use a resource's natural duration"); + if (!provider) + return eve::editing::failed( + EditorStatus::Unsupported, RuleId("editor.action.timeline.vfx-duration-provider-missing"), + "VFX natural duration requires a loaded particle provider"); + auto binding = action::ActionVfxBinding::fromPayload(item->payload, action::ActionVfxShape::State); + if (!binding) return EditorResult::failure(binding.status()); + auto duration = provider->get().naturalDuration(binding.value().uri); + if (!duration) return EditorResult::failure(duration.status()); + auto merged = item->payload; + merged.insert_or_assign("clipEndTime", duration.value().seconds()); + action::ActionTimelineEvent event{item->kind, item->trackId, itemId, item->type, item->time, merged}; + auto valid = registry_.validate(event); + if (!valid) return EditorResult::failure(valid.status()); + return editor_.editItem(itemId, item->time, item->end, item->type, std::move(merged)); +} + +} // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelinePayloadEditor.h b/src/modules/action/editor/ActionTimelinePayloadEditor.h new file mode 100644 index 00000000..b5aaa9bf --- /dev/null +++ b/src/modules/action/editor/ActionTimelinePayloadEditor.h @@ -0,0 +1,63 @@ +#pragma once + +/** @file ActionTimelinePayloadEditor.h @brief Validated partial editing for action-block payloads. */ + +#include "action/ActionNotifyRegistry.h" +#include "action/ActionVfxDuration.h" +#include "action/editor/ActionTimelineEditor.h" +#include "common/BorrowedRef.h" + +namespace eve::editor { + +/** + * @brief Applies typed partial payload changes through the canonical timeline transaction authority. + * + * The adapter borrows an editor and registry that must outlive it. Calls are owner-thread-only. + * Existing and unknown payload fields are preserved; the merged payload is validated before publication. + */ +class ActionTimelinePayloadEditor { +public: + /** @brief Construct a transient payload editor over borrowed authoritative services. */ + ActionTimelinePayloadEditor(ActionTimelineEditor& editor, const action::ActionNotifyRegistry& registry) + : editor_(editor), registry_(registry) {} + + /** @brief Return an owning copy of one editable notify or state payload. */ + [[nodiscard]] EditorResult payload(const LogicalId& itemId) const; + + /** + * @brief Merge fields into one payload and commit exactly one validated undo step. + * @param itemId Stable notify or state identity. + * @param fields Fields to replace or append; omitted fields remain unchanged. + * @return Applied or a structured lookup, registry, validation, or transaction failure. + */ + [[nodiscard]] EditorResult patch(const LogicalId& itemId, Value::Object fields); + + /** + * @brief Resize one VFX state to its authored clip interval at 1.0x playback. + * @param itemId Stable VFX notify-state identity. + * @return Applied, or a structured type, range, validation, or transaction failure. + */ + [[nodiscard]] EditorResult fitBlockToClip(const LogicalId& itemId); + + /** + * @brief Extend one VFX state's clip end so its authored interval matches the block duration. + * @param itemId Stable VFX notify-state identity. + * @return Applied, or a structured type, range, validation, or transaction failure. + */ + [[nodiscard]] EditorResult fitClipToBlock(const LogicalId& itemId); + + /** + * @brief Set one VFX state's clip end to the finite duration reported by its resource provider. + * @param itemId Stable VFX notify-state identity. + * @param provider Optional synchronously borrowed provider; absence is reported as Unsupported. + * @return Applied, or a structured provider, resource, type, validation, or transaction failure. + */ + [[nodiscard]] EditorResult fitClipToNaturalDuration( + const LogicalId& itemId, OptionalRef provider); + +private: + ActionTimelineEditor& editor_; + const action::ActionNotifyRegistry& registry_; +}; + +} // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelineScriptBindings.cpp b/src/modules/action/editor/ActionTimelineScriptBindings.cpp index 6fcf97de..1c1c4609 100644 --- a/src/modules/action/editor/ActionTimelineScriptBindings.cpp +++ b/src/modules/action/editor/ActionTimelineScriptBindings.cpp @@ -1,24 +1,37 @@ #include "action/editor/ActionTimelineScriptBindings.h" +#include "action/ActionBlockRuntime.h" #include "action/ActionNotifyRegistry.h" +#include "action/ActionParameterCurve.h" #include "action/editor/ActionEditorModule.h" +#include "action/editor/ActionPreviewController.h" #include "action/editor/ActionTimelineEditor.h" +#include "action/editor/ActionTimelinePayloadEditor.h" #include "action/editor/ActionTimelineWidget.h" #include "animation/AnimClip.h" #include "animation/AnimImporter.h" +#include "animation/AnimLayerMixer.h" #include "animation/AnimPose.h" +#include "animation/MontageCoordinator.h" #include "animation/MontagePlayer.h" +#include "common/Capability.h" #include "common/SquirrelBinding.h" #include "common/SquirrelOwnership.h" +#include "editor/EditorAssetDatabase.h" +#include "editor/EditorDiskAssetCatalog.h" +#include "editor/EditorDiskDocumentStore.h" #include "editor/EditorWorkspace.h" #include "model3d/ModelData.h" #include #include +#include #include +#include #include #include +#include #include #include #include @@ -81,6 +94,96 @@ const action::ActionNotifyState* findState(const action::ActionTimeline& timelin return nullptr; } +const action::ActionNotify* findNotify(const action::ActionTimeline& timeline, const std::string& itemId) { + for (const auto& track : timeline.tracks) { + const auto found = std::find_if(track.notifies.begin(), track.notifies.end(), + [&](const auto& notify) { return notify.id.format() == itemId; }); + if (found != track.notifies.end()) return &*found; + } + return nullptr; +} + +const action::ActionAnimationSection* findSection(const action::ActionTimeline& timeline, const std::string& itemId) { + const auto found = std::find_if(timeline.animationSections.begin(), timeline.animationSections.end(), + [&](const auto& section) { return section.id.format() == itemId; }); + return found == timeline.animationSections.end() ? nullptr : &*found; +} + +class ScriptActionTimelineAssetCatalog { +public: + explicit ScriptActionTimelineAssetCatalog(std::filesystem::path projectRoot) + : store_(projectRoot), catalog_(std::move(projectRoot), &database_) {} + + [[nodiscard]] EditorResult refresh(std::string text) { + auto scanned = catalog_.scan(); + if (!scanned.ok()) return EditorResult::failure(scanned.status()); + AssetQuery query; + query.typeIds = {"eve.action.timeline"}; + query.text = std::move(text); + auto page = database_.query(query, 0, 512); + if (!page.ok()) return EditorResult::failure(page.status()); + + entries_.clear(); + auto diagnostics = scanned.diagnostics(); + for (const auto& record : page.value().values) { + auto stored = store_.read(record.logicalUri); + if (!stored.ok()) { + diagnostics.insert(diagnostics.end(), stored.diagnostics().begin(), stored.diagnostics().end()); + continue; + } + auto timeline = action::ActionTimeline::fromValue(toPresentationValue(stored.value().content)); + if (!timeline) { + diagnostics.insert(diagnostics.end(), timeline.diagnostics().begin(), timeline.diagnostics().end()); + continue; + } + entries_.push_back(record); + } + generation_ = page.value().generation; + return eve::editing::applied(entries_.size(), std::move(diagnostics)); + } + + [[nodiscard]] EditorResult registerDocument(const std::string& guid, const std::string& uri) { + if (guid.empty() || !uri.starts_with("content://") || uri.size() <= std::string("content://").size()) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.asset-registration-invalid"), + "Action asset registration requires a GUID and content URI"); + auto stored = store_.read(uri); + if (!stored.ok()) return EditorResult::failure(stored.status()); + auto timeline = action::ActionTimeline::fromValue(toPresentationValue(stored.value().content)); + if (!timeline) return EditorResult::failure(timeline.status()); + return catalog_.writeSidecar(uri.substr(std::string("content://").size()), AssetGuid(guid), + "eve.action.timeline"); + } + + [[nodiscard]] int count() const noexcept { return static_cast(entries_.size()); } + [[nodiscard]] std::uint64_t generation() const noexcept { return generation_; } + [[nodiscard]] std::string guid(int index) const { + const auto* entry = at(index); + return entry ? entry->guid.value() : std::string{}; + } + [[nodiscard]] std::string uri(int index) const { + const auto* entry = at(index); + return entry ? entry->logicalUri : std::string{}; + } + [[nodiscard]] std::string title(int index) const { + const auto* entry = at(index); + if (!entry) return {}; + return std::filesystem::path(entry->logicalUri.substr(std::string("content://").size())).stem().string(); + } + +private: + [[nodiscard]] const AssetRecord* at(int index) const { + if (index < 0 || static_cast(index) >= entries_.size()) return nullptr; + return &entries_[static_cast(index)]; + } + + MemoryAssetDatabase database_; + DiskAtomicDocumentStore store_; + DiskAssetCatalog catalog_; + std::vector entries_; + std::uint64_t generation_ = 0; +}; + Value montageAdvanceValue(const animation::MontageAdvance& value) { Value::Object projected; projected["previousSeconds"] = value.previous.seconds(); @@ -110,19 +213,270 @@ void accumulateRootMotion(animation::TransformTRS& total, const animation::Trans total.qw = tw * delta.qw - tx * delta.qx - ty * delta.qy - tz * delta.qz; } +class ComposedActionPreviewSink final : public action::IActionPreviewSink { +public: + void add(std::unique_ptr sink) { sinks_.push_back(std::move(sink)); } + [[nodiscard]] bool empty() const noexcept { return sinks_.empty(); } + + Result prepare(const action::ActionPreviewFrame& frame) override { + std::size_t prepared = 0; + for (; prepared < sinks_.size(); ++prepared) { + auto result = sinks_[prepared]->prepare(frame); + if (result) continue; + for (std::size_t index = 0; index <= prepared; ++index) sinks_[index]->discardPrepared(); + return Result::failure(result.status()); + } + return Result::success(Status::success(StatusCode::Applied)); + } + + void present(const action::ActionPreviewFrame& frame) noexcept override { + for (auto& sink : sinks_) sink->present(frame); + } + + void discardPrepared() noexcept override { + for (auto& sink : sinks_) sink->discardPrepared(); + } + +private: + std::vector> sinks_; +}; + class ScriptActionTimelineEditor { public: ScriptActionTimelineEditor(std::string targetId, action::ActionTimeline timeline, - action::ActionNotifyRegistry registry) + action::ActionNotifyRegistry registry, + std::shared_ptr clipboard) : registry_(std::move(registry)), - editor_(std::move(targetId), std::move(timeline)), - widget_(editor_, registry_) {} + blockRuntime_(registry_), + editor_(std::move(targetId), std::move(timeline), std::move(clipboard)), + widget_(editor_, registry_) { + auto composed = std::make_unique(); + const auto providerCount = cap::listenerCount(); + for (std::size_t index = 0; index < providerCount; ++index) { + auto* provider = cap::listenerAt(index); + if (!provider) continue; + auto sink = provider->createActionPreviewSink(); + if (!sink) { + previewInitializationFailure_ = sink.status(); + return; + } + composed->add(std::move(sink).takeValue()); + } + if (composed->empty()) return; + previewSink_ = std::move(composed); + previewController_ = std::make_unique(editor_, *previewSink_); + } + + ~ScriptActionTimelineEditor() { + if (executionId_.isZero()) return; + const auto* montage = activeMontage(); + auto interrupted = blockRuntime_.interrupt(runtimeContext(montage ? montage->time() : Duration::zero())); + interrupted.ignore(); + } ActionTimelineEditor& editor() noexcept { return editor_; } ActionTimelineWidget& widget() noexcept { return widget_; } const ActionTimelineEditor& editor() const noexcept { return editor_; } const ActionTimelineWidget& widget() const noexcept { return widget_; } + const action::ActionNotifyRegistry& registry() const noexcept { return registry_; } + + [[nodiscard]] bool hasPreviewHost() const noexcept { return previewController_ != nullptr; } + + void attachDocument(std::unique_ptr store, + std::unique_ptr documents, DocumentSnapshot snapshot) { + documentStore_ = std::move(store); + documents_ = std::move(documents); + document_ = std::move(snapshot); + synchronizedEditorRevision_ = editor_.target().revision(); + savedEditorRevision_ = editor_.target().revision(); + } + + [[nodiscard]] bool documentDirty() const { + if (!documents_ || document_.id.empty()) return false; + auto snapshot = documents_->snapshot(document_.id); + return !snapshot.ok() || snapshot.value().dirty() || editor_.target().revision() != savedEditorRevision_; + } + + [[nodiscard]] EditorResult setMontageSettings(action::ActionMontageSettings settings) { + const auto previous = editor_.target().timeline().montage; + auto edited = editor_.setMontageSettings(settings); + if (!edited.ok()) return edited; + if (auto* montage = activeMontage()) { + auto applied = montage->setSettings(settings); + if (!applied) { + auto rolledBack = editor_.undo(); + if (rolledBack.ok()) { + runtimeRate_ = previous.basePlayRate; + if (runtimeTimeline_) runtimeTimeline_->montage = previous; + } + return EditorResult::failure(applied.status()); + } + } + runtimeRate_ = settings.basePlayRate; + if (runtimeTimeline_) runtimeTimeline_->montage = std::move(settings); + return edited; + } + + [[nodiscard]] EditorResult addSectionSplit(Duration time) { + const auto previous = editor_.target().timeline().splitTimestamps; + auto edited = editor_.addSectionSplit(time); + if (!edited.ok()) return edited; + const auto& splits = editor_.target().timeline().splitTimestamps; + if (auto* montage = activeMontage()) { + auto applied = montage->setSectionSplits(splits); + if (!applied) { + auto rolledBack = editor_.undo(); + if (rolledBack.ok() && runtimeTimeline_) runtimeTimeline_->splitTimestamps = previous; + return EditorResult::failure(applied.status()); + } + } + if (runtimeTimeline_) runtimeTimeline_->splitTimestamps = splits; + return edited; + } + + [[nodiscard]] EditorResult setSectionSplit(std::size_t index, Duration time) { + const auto previous = editor_.target().timeline().splitTimestamps; + auto edited = editor_.setSectionSplit(index, time); + if (!edited.ok()) return edited; + const auto& splits = editor_.target().timeline().splitTimestamps; + if (auto* montage = activeMontage()) { + auto applied = montage->setSectionSplits(splits); + if (!applied) { + auto rolledBack = editor_.undo(); + if (rolledBack.ok() && runtimeTimeline_) runtimeTimeline_->splitTimestamps = previous; + return EditorResult::failure(applied.status()); + } + } + if (runtimeTimeline_) runtimeTimeline_->splitTimestamps = splits; + return edited; + } + + [[nodiscard]] EditorResult removeSectionSplit(std::size_t index) { + const auto previous = editor_.target().timeline().splitTimestamps; + auto edited = editor_.removeSectionSplit(index); + if (!edited.ok()) return edited; + const auto& splits = editor_.target().timeline().splitTimestamps; + if (auto* montage = activeMontage()) { + auto applied = montage->setSectionSplits(splits); + if (!applied) { + auto rolledBack = editor_.undo(); + if (rolledBack.ok() && runtimeTimeline_) runtimeTimeline_->splitTimestamps = previous; + return EditorResult::failure(applied.status()); + } + } + if (runtimeTimeline_) runtimeTimeline_->splitTimestamps = splits; + return edited; + } + + [[nodiscard]] EditorResult undo() { + const auto previous = editor_.target().timeline().splitTimestamps; + auto undone = editor_.undo(); + if (!undone.ok()) return undone; + const auto& splits = editor_.target().timeline().splitTimestamps; + if (auto* montage = activeMontage()) { + auto applied = montage->setSectionSplits(splits); + if (!applied) { + auto rolledBack = editor_.redo(); + if (rolledBack.ok() && runtimeTimeline_) runtimeTimeline_->splitTimestamps = previous; + return EditorResult::failure(applied.status()); + } + } + if (runtimeTimeline_) runtimeTimeline_->splitTimestamps = splits; + return undone; + } + + [[nodiscard]] EditorResult redo() { + const auto previous = editor_.target().timeline().splitTimestamps; + auto redone = editor_.redo(); + if (!redone.ok()) return redone; + const auto& splits = editor_.target().timeline().splitTimestamps; + if (auto* montage = activeMontage()) { + auto applied = montage->setSectionSplits(splits); + if (!applied) { + auto rolledBack = editor_.undo(); + if (rolledBack.ok() && runtimeTimeline_) runtimeTimeline_->splitTimestamps = previous; + return EditorResult::failure(applied.status()); + } + } + if (runtimeTimeline_) runtimeTimeline_->splitTimestamps = splits; + return redone; + } + + [[nodiscard]] EditorResult saveDocument() { + auto synchronized = synchronizeDocument(); + if (!synchronized.ok()) return synchronized; + auto ticket = documents_->requestSave(document_.id); + if (!ticket.ok()) return EditorResult::failure(ticket.status()); + auto saved = documents_->executeSave(ticket.value()); + if (saved.ok()) { + document_ = saved.value(); + savedEditorRevision_ = editor_.target().revision(); + } + return saved; + } + + [[nodiscard]] EditorResult reconcileDocument() { + auto synchronized = synchronizeDocument(); + if (!synchronized.ok()) return synchronized; + auto reconciled = documents_->reconcileExternal(document_.id, [](const EditorValue& content) { + auto timeline = action::ActionTimeline::fromValue(toPresentationValue(content)); + if (!timeline) return EditorResult::failure(timeline.status()); + return eve::editing::applied(); + }); + if (!reconciled.ok()) return reconciled; + auto content = documents_->content(document_.id); + if (!content.ok()) return EditorResult::failure(content.status()); + if (content.value() != editor_.target().snapshotValue()) { + auto loaded = editor_.reloadDocument(content.value()); + if (!loaded.ok()) return EditorResult::failure(loaded.status()); + synchronizedEditorRevision_ = editor_.target().revision(); + savedEditorRevision_ = editor_.target().revision(); + } + document_ = reconciled.value(); + return reconciled; + } + + const DocumentSnapshot& document() const noexcept { return document_; } + + [[nodiscard]] EditorResult seekPreview(Duration time) { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->seek(time) : editor_.seek(time); + } + + [[nodiscard]] EditorResult seekPreviewAt(float x) { return seekPreview(widget_.timeAt(x)); } + + [[nodiscard]] EditorResult stopPreview() { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->stop() : editor_.stop(); + } + + [[nodiscard]] EditorResult stepPreviewFrames(std::int64_t frames, double frameRate) { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->stepFrames(frames, frameRate) + : editor_.stepFrames(frames, frameRate); + } + + [[nodiscard]] EditorResult jumpPreviewToEnd() { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->jumpToEnd() : editor_.jumpToEnd(); + } + + [[nodiscard]] EditorResult updatePreview(Duration delta) { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->update(delta) : editor_.update(delta); + } + + [[nodiscard]] EditorResult refreshPreview() { + if (previewInitializationFailure_) + return EditorResult::failure(*previewInitializationFailure_); + return previewController_ ? previewController_->refresh() : eve::editing::noOp(); + } [[nodiscard]] Result registerRuntimeClip(std::string uri, model3d::ModelData& model, animation::AnimSkeleton& skeleton, int animationIndex) { @@ -137,23 +491,39 @@ public: if (!clip) return Result::failure( Diagnostic::error(DiagnosticCode::Failed, "montage clip import produced no animation", uri)); - if (montage_) return montage_->replaceClip(uri, std::move(clip)); + const auto found = std::find_if(runtimeClips_.begin(), runtimeClips_.end(), + [&](const auto& asset) { return asset.uri == uri; }); + if (auto* montage = activeMontage()) { + auto applied = montage->replaceClip(uri, clip->clone()); + if (!applied) return applied; + } + if (found == runtimeClips_.end()) runtimeClips_.push_back({std::move(uri), std::move(clip)}); + else found->clip = std::move(clip); + return Result::success(Status::success(StatusCode::Applied)); + } + + [[nodiscard]] Result replaceRuntimeClip(std::string uri, const animation::AnimClip& clip) { + if (uri.empty()) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, "montage clip URI must not be empty", "uri")); const auto found = std::find_if(runtimeClips_.begin(), runtimeClips_.end(), [&](const auto& asset) { return asset.uri == uri; }); if (found == runtimeClips_.end()) - runtimeClips_.push_back({std::move(uri), std::move(clip)}); - else - found->clip = std::move(clip); + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "montage runtime clip was not registered", uri)); + if (auto* montage = activeMontage()) { + auto applied = montage->replaceClip(uri, clip.clone()); + if (!applied) return applied; + } + found->clip = clip.clone(); return Result::success(Status::success(StatusCode::Applied)); } [[nodiscard]] Result beginRuntime(animation::AnimSkeleton& skeleton) { - auto montage = std::make_unique(skeleton); auto timeline = editor_.target().timeline(); - auto prepared = montage->prepare(timeline, std::move(runtimeClips_)); - if (!prepared) return Result::failure(prepared.status()); runtimeTimeline_ = timeline; - montage_ = std::move(montage); + coordinator_ = std::make_unique(skeleton); + montageHandle_ = animation::MontageHandle::invalid(); tick_ = SimulationTick::zero(); runtimeAdvance_.reset(); runtimeRate_ = timeline.montage.basePlayRate; @@ -163,23 +533,24 @@ public: } [[nodiscard]] Result advanceRuntime(Duration delta) { - if (!runtime_ || !montage_) + auto* montage = activeMontage(); + if (!runtime_ || !montage) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); - if (montage_->isBlendingOut()) { + if (montage->isBlendingOut()) { tick_ = SimulationTick(tick_.value() + 1); - auto settled = montage_->advanceBlendOut(delta, tick_); + auto settled = montage->advanceBlendOut(delta, tick_); if (settled) runtimeAdvance_ = settled.value(); return settled; } if (runtimePaused_) { animation::MontageAdvance paused; - paused.previous = paused.current = montage_->time(); - paused.weight = montage_->weight(); + paused.previous = paused.current = montage->time(); + paused.weight = montage->weight(); return Result::success(std::move(paused), Status::success(StatusCode::NoOp)); } double sectionRate = 1.0; - auto sectionIndex = montage_->physicalSectionIndex(); + auto sectionIndex = montage->physicalSectionIndex(); if (sectionIndex) { const auto found = sectionRates_.find(sectionIndex.value()); if (found != sectionRates_.end()) sectionRate = found->second; @@ -188,14 +559,15 @@ public: if (!scaledDelta) return Result::failure(scaledDelta.status()); Duration remainingDelta = std::move(scaledDelta).takeValue(); animation::MontageAdvance combined; - combined.previous = montage_->time(); + combined.previous = montage->time(); bool firstSlice = true; while (true) { Duration remainingTimeline = - Duration::fromNanoseconds(runtimeTimeline_->duration.nanoseconds() - montage_->time().nanoseconds()); + Duration::fromNanoseconds(runtimeTimeline_->duration.nanoseconds() - montage->time().nanoseconds()); if (runtimeTimeline_->montage.looping && remainingTimeline.isZero()) { auto restarted = restartExecution(); if (!restarted) return Result::failure(restarted.status()); + montage = activeMontage(); remainingTimeline = runtimeTimeline_->duration; } const Duration slice = @@ -203,8 +575,12 @@ public: tick_ = SimulationTick(tick_.value() + 1); auto actionAdvance = runtime_->advance(executionId_, tick_, slice); if (!actionAdvance) return Result::failure(actionAdvance.status()); - auto presented = montage_->present(actionAdvance.value(), tick_); + auto routed = routeAvailableBlocks(actionAdvance.value()); + if (!routed) return Result::failure(routed.status()); + auto presented = coordinator_->present(montageHandle_, actionAdvance.value(), tick_); if (!presented) return Result::failure(presented.status()); + auto faded = coordinator_->advanceBlendOuts(slice, tick_, montageHandle_); + if (!faded) return Result::failure(faded.status()); auto value = std::move(presented).takeValue(); if (firstSlice) { combined = std::move(value); @@ -232,24 +608,45 @@ public: combined.sectionId.reset(); combined.activeBlocks.clear(); combined.completed = false; - combined.weight = montage_->weight(); + montage = activeMontage(); + combined.weight = montage ? montage->weight() : 0.0; } runtimeAdvance_ = combined; return Result::success(std::move(combined), Status::success(StatusCode::Pending)); } + [[nodiscard]] Result setRuntimeBlockExternallyHandled(const std::string& type, bool external) { + auto parsed = LogicalId::parse(type); + if (!parsed) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, + "externally handled runtime block type is invalid", "type")); + if (!registry_.hasHandler(type)) + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, + "runtime block type has no registered handler", type)); + if (external) + externallyHandledTypes_.insert(type); + else + externallyHandledTypes_.erase(type); + return Result::success(Status::success(StatusCode::Applied)); + } + [[nodiscard]] Result jumpRuntime(Duration target) { - if (!runtimeTimeline_ || !montage_) + auto* montage = activeMontage(); + if (!runtimeTimeline_ || !montage) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); auto restarted = restartExecution(false); if (!restarted) return Result::failure(restarted.status()); - auto rebound = montage_->rebindExecution(executionId_); + auto rebound = montage->rebindExecution(executionId_); if (!rebound) return Result::failure(rebound.status()); tick_ = SimulationTick(tick_.value() + 1); auto advanced = runtime_->advance(executionId_, tick_, target); if (!advanced) return Result::failure(advanced.status()); - auto jumped = montage_->jumpToTime(executionId_, target, tick_); + auto routed = routeAvailableBlocks(advanced.value()); + if (!routed) return Result::failure(routed.status()); + auto jumped = montage->jumpToTime(executionId_, target, tick_); if (jumped) runtimeAdvance_ = jumped.value(); return jumped; } @@ -263,6 +660,29 @@ public: return jumpRuntime(range.value().first); } + [[nodiscard]] Result evaluateRuntimeSectionProgress(std::size_t index, + double progress) { + if (!runtimeTimeline_) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + if (!std::isfinite(progress) || progress < 0.0 || progress > 1.0) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, + "runtime section progress must be finite and within [0,1]", "progress")); + auto range = runtimeTimeline_->sectionRange(index); + if (!range) return Result::failure(range.status()); + const auto span = range.value().second.nanoseconds() - range.value().first.nanoseconds(); + return jumpRuntime(Duration::fromNanoseconds( + range.value().first.nanoseconds() + static_cast(std::llround(span * progress)))); + } + + [[nodiscard]] Result replayRuntimeCrossFade() { + if (!runtimeTimeline_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return restartExecution(); + } + [[nodiscard]] Result syncRuntimeSection(std::size_t index, Duration targetDuration) { if (!runtimeTimeline_) return Result::failure( @@ -277,12 +697,70 @@ public: return Result::success(Status::success(StatusCode::Applied)); } + [[nodiscard]] Result syncRuntimeSectionAndJump(std::size_t index, + Duration targetDuration) { + auto synchronized = syncRuntimeSection(index, targetDuration); + if (!synchronized) return Result::failure(synchronized.status()); + return jumpRuntimeSection(index); + } + + [[nodiscard]] Result clearRuntimeSectionSync(std::size_t index) { + if (!runtimeTimeline_) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + auto range = runtimeTimeline_->sectionRange(index); + if (!range) return Result::failure(range.status()); + return Result::success(Status::success(sectionRates_.erase(index) > 0 ? StatusCode::Applied + : StatusCode::NoOp)); + } + + [[nodiscard]] Result setRuntimeBoneMask(const animation::AnimBoneMask& mask) { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->setLayerBoneMask(activeLayer_, mask); + } + + [[nodiscard]] Result clearRuntimeBoneMask() { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->clearLayerBoneMask(activeLayer_); + } + + [[nodiscard]] Result setRuntimeLayerWeight(float weight) { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->setLayerWeight(activeLayer_, weight); + } + + [[nodiscard]] float runtimeLayerWeight() const { + if (!coordinator_ || !activeMontage()) return 0.0f; + auto weight = coordinator_->layerWeight(activeLayer_); + return weight ? weight.value() : 0.0f; + } + + [[nodiscard]] Result setRuntimeLayerAdditive(bool additive) { + if (!coordinator_ || !activeMontage()) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + return coordinator_->setLayerAdditive(activeLayer_, additive); + } + + [[nodiscard]] bool runtimeLayerAdditive() const { + if (!coordinator_ || !activeMontage()) return false; + auto additive = coordinator_->layerAdditive(activeLayer_); + return additive ? additive.value() : false; + } + [[nodiscard]] Result beginRuntimeBlendOut(Duration duration) { - if (!montage_) + auto* montage = activeMontage(); + if (!montage) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); tick_ = SimulationTick(tick_.value() + 1); - auto stopped = montage_->beginBlendOut(duration, tick_); + auto stopped = montage->beginBlendOut(duration, tick_); if (stopped) runtimeAdvance_ = stopped.value(); return stopped; } @@ -290,36 +768,132 @@ public: void setRuntimePaused(bool paused) noexcept { runtimePaused_ = paused; } [[nodiscard]] bool runtimePaused() const noexcept { return runtimePaused_; } - [[nodiscard]] Result cancelRuntime() { - if (!runtime_ || !montage_) return Result::success(Status::success(StatusCode::NoOp)); - tick_ = SimulationTick(tick_.value() + 1); - auto cancelled = runtime_->cancel(executionId_, tick_); - if (!cancelled) return cancelled; - montage_->stop(); + [[nodiscard]] Result setRuntimeRate(double rate) { + if (!runtimeTimeline_) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); + if (!std::isfinite(rate) || rate <= 0.0) + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, + "montage runtime rate must be finite and positive", "rate")); + if (runtimeRate_ == rate) return Result::success(Status::success(StatusCode::NoOp)); + runtimeRate_ = rate; return Result::success(Status::success(StatusCode::Applied)); } - [[nodiscard]] animation::AnimPose* runtimePose() noexcept { return montage_ ? &montage_->pose() : nullptr; } + [[nodiscard]] double runtimeRate() const noexcept { return runtimeRate_; } + + [[nodiscard]] Result cancelRuntime() { + auto* montage = activeMontage(); + if (!runtime_ || !montage) return Result::success(Status::success(StatusCode::NoOp)); + tick_ = SimulationTick(tick_.value() + 1); + auto cancelled = runtime_->cancel(executionId_, tick_); + if (!cancelled) return cancelled; + auto interrupted = blockRuntime_.interrupt(runtimeContext(montage->time())); + if (!interrupted) return interrupted; + montage->stop(); + return Result::success(Status::success(StatusCode::Applied)); + } + + [[nodiscard]] animation::AnimPose* runtimePose() noexcept { + if (!coordinator_ || !montageHandle_.isValid()) return nullptr; + auto pose = coordinator_->pose(activeLayer_); + return pose ? &pose.value().get() : nullptr; + } + [[nodiscard]] animation::AnimPose* runtimePoseOverBase(const animation::AnimPose& basePose) { + if (!coordinator_ || !montageHandle_.isValid()) return nullptr; + auto pose = coordinator_->compose(basePose); + return pose ? &pose.value().get() : nullptr; + } [[nodiscard]] const std::optional& runtimeAdvance() const noexcept { return runtimeAdvance_; } - [[nodiscard]] bool runtimePlaying() const noexcept { return montage_ && montage_->isPlaying(); } + [[nodiscard]] bool runtimePlaying() const noexcept { + const auto* montage = activeMontage(); + return montage && montage->isPlaying(); + } + + [[nodiscard]] std::string runtimeState() const { + if (!runtimeTimeline_) return "not-started"; + if (runtimeAdvance_ && runtimeAdvance_->completed) return "finished"; + const auto* montage = activeMontage(); + if (!montage) return "finished"; + if (montage->isBlendingOut()) return "stopping"; + if (runtimePaused_) return "paused"; + return montage->isPlaying() ? "playing" : "finished"; + } + + [[nodiscard]] double runtimeElapsedSeconds() const noexcept { + const auto* montage = activeMontage(); + if (montage) return montage->time().seconds(); + return runtimeAdvance_ ? runtimeAdvance_->current.seconds() : 0.0; + } + + [[nodiscard]] double runtimeDurationSeconds() const noexcept { + return runtimeTimeline_ ? runtimeTimeline_->duration.seconds() : 0.0; + } + + [[nodiscard]] int runtimeLayer() const noexcept { + return montageHandle_.isValid() ? static_cast(activeLayer_) : -1; + } + + [[nodiscard]] int runtimeSlot() const noexcept { + return montageHandle_.isValid() ? static_cast(montageHandle_.index() % 2U) : -1; + } [[nodiscard]] Result runtimePhysicalSection() const { - if (!montage_) + const auto* montage = activeMontage(); + if (!montage) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); - return montage_->physicalSectionIndex(); + return montage->physicalSectionIndex(); } [[nodiscard]] Result runtimeSectionProgress(std::size_t index) const { - if (!montage_) + const auto* montage = activeMontage(); + if (!montage) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage runtime has not been started", "runtime")); - return montage_->physicalSectionProgress(index); + return montage->physicalSectionProgress(index); } private: + [[nodiscard]] animation::MontagePlayer* activeMontage() noexcept { + if (!coordinator_ || !montageHandle_.isValid()) return nullptr; + auto resolved = coordinator_->resolve(montageHandle_); + return resolved ? &resolved.value().get() : nullptr; + } + + [[nodiscard]] const animation::MontagePlayer* activeMontage() const noexcept { + if (!coordinator_ || !montageHandle_.isValid()) return nullptr; + auto resolved = static_cast(*coordinator_).resolve(montageHandle_); + return resolved ? &resolved.value().get() : nullptr; + } + + [[nodiscard]] std::vector cloneRuntimeClips() const { + std::vector clips; + clips.reserve(runtimeClips_.size()); + for (const auto& asset : runtimeClips_) + clips.push_back({asset.uri, asset.clip ? asset.clip->clone() : nullptr}); + return clips; + } + + [[nodiscard]] EditorResult synchronizeDocument() { + if (!documents_ || document_.id.empty()) + return eve::editing::failed(EditorStatus::Unsupported, + RuleId("editor.action.document.not-persistent"), + "Action timeline editor is not attached to a document"); + auto snapshot = documents_->snapshot(document_.id); + if (!snapshot.ok()) return snapshot; + if (editor_.target().revision() == synchronizedEditorRevision_) return snapshot; + auto edited = documents_->edit(document_.id, editor_.target().snapshotValue(), snapshot.value().revision.edit); + if (edited.ok()) { + synchronizedEditorRevision_ = editor_.target().revision(); + document_ = edited.value(); + } + return edited; + } + [[nodiscard]] action::ActionDefinition runtimeDefinition() const { action::ActionDefinition definition; definition.id = runtimeTimeline_->actionId; @@ -337,6 +911,14 @@ private: } [[nodiscard]] Result restartExecution(bool playMontage = true) { + const bool hasActiveMontage = activeMontage() != nullptr; + const auto previousHandle = montageHandle_; + const auto previousLayer = activeLayer_; + if (!executionId_.isZero()) { + const auto* montage = activeMontage(); + auto interrupted = blockRuntime_.interrupt(runtimeContext(montage ? montage->time() : Duration::zero())); + if (!interrupted) return interrupted; + } auto runtime = std::make_unique(); auto definition = runtimeDefinition(); action::ActionRequest request; @@ -344,29 +926,104 @@ private: auto submitted = runtime->submit(std::move(definition), std::move(request)); if (!submitted) return Result::failure(submitted.status()); if (playMontage) { - auto played = montage_->play(submitted.value()); + if (!coordinator_) + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "montage coordinator has not been started", "runtime")); + if (hasActiveMontage) tick_ = SimulationTick(tick_.value() + 1); + const std::size_t layer = runtimeTimeline_->montage.animationLayer; + auto played = coordinator_->play(layer, *runtimeTimeline_, cloneRuntimeClips(), submitted.value(), tick_); if (!played) return Result::failure(played.status()); + montageHandle_ = played.value(); + activeLayer_ = layer; + if (hasActiveMontage && previousLayer != layer) { + auto previous = coordinator_->resolve(previousHandle); + if (previous) previous.value().get().stop(); + coordinator_->collectFinished(); + } } runtime_ = std::move(runtime); executionId_ = submitted.value(); return Result::success(Status::success(StatusCode::Applied)); } + [[nodiscard]] action::ActionNotifyContext runtimeContext(Duration time) const { + action::ActionNotifyContext context; + context.executionId = executionId_; + context.time = time; + return context; + } + + [[nodiscard]] Result routeAvailableBlocks(const action::ActionAdvance& advance) { + action::ActionAdvance handled; + handled.id = advance.id; + handled.phase = advance.phase; + handled.phaseElapsed = advance.phaseElapsed; + handled.totalElapsed = advance.totalElapsed; + for (const auto& event : advance.timelineEvents) + if (registry_.hasHandler(event.type.format()) && + !externallyHandledTypes_.contains(event.type.format())) + handled.timelineEvents.push_back(event); + for (const auto& block : advance.activeBlocks) + if (registry_.hasHandler(block.type.format()) && + !externallyHandledTypes_.contains(block.type.format())) + handled.activeBlocks.push_back(block); + return blockRuntime_.apply(handled, runtimeContext(advance.totalElapsed)); + } + action::ActionNotifyRegistry registry_; + action::ActionBlockRuntime blockRuntime_; ActionTimelineEditor editor_; ActionTimelineWidget widget_; + std::unique_ptr previewSink_; + std::unique_ptr previewController_; + std::optional previewInitializationFailure_; std::vector runtimeClips_; std::unique_ptr runtime_; - std::unique_ptr montage_; + std::unique_ptr coordinator_; + animation::MontageHandle montageHandle_ = animation::MontageHandle::invalid(); + std::size_t activeLayer_ = 0; action::ActionExecutionId executionId_{}; SimulationTick tick_ = SimulationTick::zero(); std::optional runtimeAdvance_; std::optional runtimeTimeline_; std::unordered_map sectionRates_; + std::set> externallyHandledTypes_; double runtimeRate_ = 1.0; bool runtimePaused_ = false; + std::unique_ptr documentStore_; + std::unique_ptr documents_; + DocumentSnapshot document_; + std::uint64_t synchronizedEditorRevision_ = 0; + std::uint64_t savedEditorRevision_ = 0; }; +std::optional itemPayloadField(ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field) { + if (!self) return std::nullopt; + auto parsed = LogicalId::parse(itemId); + if (!parsed) return std::nullopt; + ActionTimelinePayloadEditor payloads(self->editor(), self->registry()); + auto payload = payloads.payload(*parsed); + if (!payload.ok()) return std::nullopt; + const auto found = payload.value().find(field); + return found == payload.value().end() ? std::nullopt : std::optional(found->second); +} + +EditorResult patchItemPayload(ScriptActionTimelineEditor* self, const std::string& itemId, + Value::Object fields) { + if (!self) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.payload-editor-null"), + "Action timeline editor is null"); + auto parsed = LogicalId::parse(itemId); + if (!parsed) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.payload-item-invalid"), + "Action-block identity is invalid"); + ActionTimelinePayloadEditor payloads(self->editor(), self->registry()); + return payloads.patch(*parsed, std::move(fields)); +} + std::string eventKind(action::ActionTimelineEventKind kind) { switch (kind) { case action::ActionTimelineEventKind::Notify: return "notify"; @@ -380,9 +1037,39 @@ std::string eventKind(action::ActionTimelineEventKind kind) { void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleClass) { const HSQUIRRELVM vm = table.getHandle(); + auto clipboard = std::make_shared(); auto actionEditor = table.addClass( "ActionTimelineEditor", std::function([]() -> ScriptActionTimelineEditor* { return nullptr; }), true); + auto assetCatalog = table.addClass( + "ActionTimelineAssetCatalog", + std::function([]() -> ScriptActionTimelineAssetCatalog* { + return nullptr; + }), + true); + assetCatalog.addFunc("refresh", [vm](ScriptActionTimelineAssetCatalog* self, const std::string& text) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action asset catalog is null"); + auto refreshed = self->refresh(text); + return project(vm, refreshed, + refreshed.ok() ? Value(static_cast(refreshed.value())) : Value{}); + }); + assetCatalog.addFunc("getAssetCount", + [](ScriptActionTimelineAssetCatalog* self) { return self ? self->count() : 0; }); + assetCatalog.addFunc("getGeneration", [](ScriptActionTimelineAssetCatalog* self) { + return self ? static_cast(self->generation()) : std::int64_t{0}; + }); + assetCatalog.addFunc("getAssetGuid", + [](ScriptActionTimelineAssetCatalog* self, int index) { return self ? self->guid(index) : ""; }); + assetCatalog.addFunc("getAssetUri", + [](ScriptActionTimelineAssetCatalog* self, int index) { return self ? self->uri(index) : ""; }); + assetCatalog.addFunc("getAssetTitle", [](ScriptActionTimelineAssetCatalog* self, int index) { + return self ? self->title(index) : ""; + }); + assetCatalog.addFunc("registerDocument", [vm](ScriptActionTimelineAssetCatalog* self, const std::string& guid, + const std::string& uri) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action asset catalog is null"); + return project(vm, self->registerDocument(guid, uri)); + }); actionEditor.addFunc("configureWorkspace", [vm](ScriptActionTimelineEditor* self, EditorWorkspace* workspace) { if (!self || !workspace) @@ -396,6 +1083,42 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); return project(vm, self->widget().setViewport(width, rowHeight, labelWidth)); }); + actionEditor.addFunc("addNotifyAtCursor", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, + const std::string& type, const std::string& payloadJson) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto parsedTrack = LogicalId::parse(trackId); + if (!parsedTrack) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId"); + auto parsedPayload = Value::fromJson(payloadJson); + if (!parsedPayload) return script::projectStatusResult(vm, parsedPayload.status(), false, false); + const auto* payload = parsedPayload.value().getIf(); + if (!payload) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "notify payload must be a JSON object", + "payloadJson"); + return project(vm, self->widget().addNotifyAtCursor(*parsedTrack, type, *payload)); + }); + actionEditor.addFunc("addStateAtCursor", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, + const std::string& type, float durationSeconds, + const std::string& payloadJson) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto parsedTrack = LogicalId::parse(trackId); + auto duration = seconds(durationSeconds); + if (!parsedTrack) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId"); + if (!duration) return script::projectStatusResult(vm, duration.status(), false, false); + auto parsedPayload = Value::fromJson(payloadJson); + if (!parsedPayload) return script::projectStatusResult(vm, parsedPayload.status(), false, false); + const auto* payload = parsedPayload.value().getIf(); + if (!payload) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "notify-state payload must be a JSON object", + "payloadJson"); + return project(vm, self->widget().addStateAtCursor(*parsedTrack, type, duration.value(), *payload)); + }); + actionEditor.addFunc("handleTimelineShortcut", [vm](ScriptActionTimelineEditor* self, + const std::string& shortcut) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + return project(vm, self->widget().handleShortcut(shortcut)); + }); actionEditor.addFunc("addTrack", [vm](ScriptActionTimelineEditor* self, const std::string& trackId, const std::string& label, const std::string& kind) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); @@ -436,6 +1159,30 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId"); return project(vm, self->editor().setTrackLocked(*parsed, locked)); }); + actionEditor.addFunc("copyTrack", [vm](ScriptActionTimelineEditor* self, const std::string& trackId) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto parsed = LogicalId::parse(trackId); + if (!parsed) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId"); + return project(vm, self->editor().copyTrack(*parsed)); + }); + actionEditor.addFunc("pasteTrack", [vm](ScriptActionTimelineEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto pasted = self->editor().pasteTrack(); + return project(vm, pasted, pasted.ok() ? Value(pasted.value().format()) : Value{}); + }); + actionEditor.addFunc( + "pasteSelectionToTrack", + [vm](ScriptActionTimelineEditor* self, const std::string& trackId, float offsetSeconds) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto parsed = LogicalId::parse(trackId); + auto offset = seconds(offsetSeconds); + if (!parsed) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "track id is invalid", "trackId"); + if (!offset) return script::projectStatusResult(vm, offset.status(), false, false); + auto pasted = self->editor().pasteToTrack(*parsed, offset.value()); + return project(vm, pasted, + pasted.ok() ? Value(static_cast(pasted.value())) : Value{}); + }); actionEditor.addFunc("setSnapSeconds", [vm](ScriptActionTimelineEditor* self, float intervalSeconds) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); @@ -483,14 +1230,14 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla actionEditor.addFunc("seekX", [vm](ScriptActionTimelineEditor* self, float x) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); - return project(vm, self->widget().seek(x)); + return project(vm, self->seekPreviewAt(x)); }); actionEditor.addFunc("seekSeconds", [vm](ScriptActionTimelineEditor* self, float value) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); auto duration = seconds(value); if (!duration) return script::projectStatusResult(vm, duration.status(), false, false); - return project(vm, self->editor().seek(std::move(duration).takeValue())); + return project(vm, self->seekPreview(std::move(duration).takeValue())); }); actionEditor.addFunc("resizeState", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, float startSeconds, float endSeconds) { @@ -506,16 +1253,165 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return project( vm, self->editor().resizeState(*parsedItemId, std::move(start).takeValue(), std::move(end).takeValue())); }); + actionEditor.addFunc("setItemTiming", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + float startSeconds, float endSeconds) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + auto parsedItemId = LogicalId::parse(itemId); + auto start = seconds(startSeconds); + auto end = seconds(endSeconds); + if (!parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is not canonical", "itemId"); + if (!start) return script::projectStatusResult(vm, start.status(), false, false); + if (!end) return script::projectStatusResult(vm, end.status(), false, false); + const auto& timeline = self->editor().target().timeline(); + if (const auto* section = findSection(timeline, itemId)) + return project(vm, self->editor().resizeAnimationSection(*parsedItemId, start.value(), end.value(), + section->blendIn)); + if (const auto* state = findState(timeline, itemId)) + return project(vm, self->editor().editItem(*parsedItemId, start.value(), end.value(), state->type, + state->payload)); + if (const auto* notify = findNotify(timeline, itemId)) + return project(vm, self->editor().editItem(*parsedItemId, start.value(), start.value(), notify->type, + notify->payload)); + return bindingFailure(vm, DiagnosticCode::NotFound, "timeline item was not found", "itemId"); + }); + actionEditor.addFunc("removeItem", [vm](ScriptActionTimelineEditor* self, const std::string& itemId) { + auto parsedItemId = LogicalId::parse(itemId); + if (!self || !parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is invalid", "itemId"); + return project(vm, self->editor().removeItem(*parsedItemId)); + }); + actionEditor.addFunc("setItemEnabled", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + bool enabled) { + auto parsedItemId = LogicalId::parse(itemId); + if (!self || !parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is invalid", "itemId"); + return project(vm, self->editor().setItemEnabled(*parsedItemId, enabled)); + }); + actionEditor.addFunc("fitBlockToClip", [vm](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + auto parsedItemId = LogicalId::parse(itemId); + if (!parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is invalid", "itemId"); + ActionTimelinePayloadEditor payloads(self->editor(), self->registry()); + return project(vm, payloads.fitBlockToClip(*parsedItemId)); + }); + actionEditor.addFunc("fitClipToBlock", [vm](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + auto parsedItemId = LogicalId::parse(itemId); + if (!parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is invalid", "itemId"); + ActionTimelinePayloadEditor payloads(self->editor(), self->registry()); + return project(vm, payloads.fitClipToBlock(*parsedItemId)); + }); + actionEditor.addFunc("fitClipToNaturalDuration", + [vm](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + auto parsedItemId = LogicalId::parse(itemId); + if (!parsedItemId) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item id is invalid", "itemId"); + const auto* provider = cap::query(); + ActionTimelinePayloadEditor payloads(self->editor(), self->registry()); + OptionalRef borrowed; + if (provider) borrowed = std::cref(*provider); + return project(vm, payloads.fitClipToNaturalDuration(*parsedItemId, borrowed)); + }); + actionEditor.addFunc("hasVfxDurationProvider", [](ScriptActionTimelineEditor*) { + return cap::query() != nullptr; + }); + actionEditor.addFunc("editItemDetails", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& type, const std::string& payloadJson) { + auto parsedItemId = LogicalId::parse(itemId); + auto parsedType = LogicalId::parse(type); + if (!self || !parsedItemId || !parsedType) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item identity or type is invalid", + "itemDetails"); + auto parsedPayload = Value::fromJson(payloadJson); + if (!parsedPayload) return script::projectStatusResult(vm, parsedPayload.status(), false, false); + const auto* payload = parsedPayload.value().getIf(); + if (!payload) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "timeline item payload must be a JSON object", + "payloadJson"); + const auto& timeline = self->editor().target().timeline(); + const auto* state = findState(timeline, itemId); + const auto* notify = findNotify(timeline, itemId); + if (!state && !notify) + return bindingFailure(vm, DiagnosticCode::NotFound, "editable notify item was not found", "itemId"); + auto descriptor = self->registry().descriptor(type); + if (!descriptor) return script::projectStatusResult(vm, descriptor.status(), false, false); + const auto expected = state ? action::ActionNotifyShape::State : action::ActionNotifyShape::Instant; + if (descriptor.value().shape != expected) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "notify type shape does not match the selected item", "type"); + const LogicalId* trackId = nullptr; + for (const auto& track : timeline.tracks) { + const auto contains = state ? std::any_of(track.states.begin(), track.states.end(), + [&](const auto& value) { return value.id == *parsedItemId; }) + : std::any_of(track.notifies.begin(), track.notifies.end(), + [&](const auto& value) { return value.id == *parsedItemId; }); + if (contains) { + trackId = &track.id; + break; + } + } + if (!trackId) + return bindingFailure(vm, DiagnosticCode::NotFound, "timeline item track was not found", "itemId"); + action::ActionTimelineEvent event{state ? action::ActionTimelineEventKind::StateEnter + : action::ActionTimelineEventKind::Notify, + *trackId, + *parsedItemId, + *parsedType, + state ? state->start : notify->time, + *payload}; + auto valid = self->registry().validate(event); + if (!valid) return script::projectStatusResult(vm, valid.status(), false, false); + return project(vm, self->editor().updateItem(*parsedItemId, *parsedType, *payload)); + }); + actionEditor.addFunc("addParameterKey", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + float time, float value, float inTangent, float outTangent, + const std::string& interpolation) { + auto parsedId = LogicalId::parse(itemId); + auto parsedInterpolation = action::actionParameterInterpolationFromName(interpolation); + if (!self || !parsedId || !parsedInterpolation) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "parameter item id or interpolation is invalid", "parameterKey"); + return project(vm, self->editor().addParameterKey( + *parsedId, {time, value, inTangent, outTangent, *parsedInterpolation})); + }); + actionEditor.addFunc("editParameterKey", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + int index, float time, float value, float inTangent, + float outTangent, const std::string& interpolation) { + auto parsedId = LogicalId::parse(itemId); + auto parsedInterpolation = action::actionParameterInterpolationFromName(interpolation); + if (!self || !parsedId || index < 0 || !parsedInterpolation) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "parameter key identity or interpolation is invalid", "parameterKey"); + return project(vm, self->editor().editParameterKey( + *parsedId, static_cast(index), + {time, value, inTangent, outTangent, *parsedInterpolation})); + }); + actionEditor.addFunc("removeParameterKey", [vm](ScriptActionTimelineEditor* self, + const std::string& itemId, int index) { + auto parsedId = LogicalId::parse(itemId); + if (!self || !parsedId || index < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "parameter key identity is invalid", "parameterKey"); + return project(vm, self->editor().removeParameterKey(*parsedId, static_cast(index))); + }); actionEditor.addFunc("undo", [vm](ScriptActionTimelineEditor* self) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); - auto result = self->editor().undo(); + auto result = self->undo(); return project(vm, result, Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); }); actionEditor.addFunc("redo", [vm](ScriptActionTimelineEditor* self) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); - auto result = self->editor().redo(); + auto result = self->redo(); return project(vm, result, Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); }); actionEditor.addFunc("update", [vm](ScriptActionTimelineEditor* self, float deltaSeconds) { @@ -523,15 +1419,33 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); auto delta = seconds(deltaSeconds); if (!delta) return script::projectStatusResult(vm, delta.status(), false, false); - auto result = self->editor().update(std::move(delta).takeValue()); + auto result = self->updatePreview(std::move(delta).takeValue()); return project(vm, result, Value(result.ok() ? static_cast(result.value()) : 0)); }); + actionEditor.addFunc("refreshPreview", [vm](ScriptActionTimelineEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + return project(vm, self->refreshPreview()); + }); + actionEditor.addFunc("hasPreviewHost", [](ScriptActionTimelineEditor* self) { + return self && self->hasPreviewHost(); + }); actionEditor.addFunc("snapshot", [vm](ScriptActionTimelineEditor* self) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); return script::projectResult(vm, self->editor().target().timeline().toValue(), [](Value value) { return value; }); }); + actionEditor.addFunc("snapshotJson", [vm](ScriptActionTimelineEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + auto snapshot = self->editor().target().timeline().toValue(); + if (!snapshot) return script::projectStatusResult(vm, snapshot.status(), false, false); + auto encoded = snapshot.value().toJson(); + return script::projectResult(vm, std::move(encoded), + [](std::string value) { return Value(std::move(value)); }); + }); actionEditor.addFunc( "registerRuntimeClip", [vm](ScriptActionTimelineEditor* self, const std::string& uri, model3d::ModelData* model, animation::AnimSkeleton* skeleton, int animationIndex) { @@ -540,6 +1454,14 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla "editor, model and skeleton must not be null", "runtimeClip"); return script::projectResult(vm, self->registerRuntimeClip(uri, *model, *skeleton, animationIndex)); }); + actionEditor.addFunc("replaceRuntimeClip", + [vm](ScriptActionTimelineEditor* self, const std::string& uri, + animation::AnimClip* clip) { + if (!self || !clip) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "editor and animation clip must not be null", "runtimeClip"); + return script::projectResult(vm, self->replaceRuntimeClip(uri, *clip)); + }); actionEditor.addFunc("beginRuntime", [vm](ScriptActionTimelineEditor* self, animation::AnimSkeleton* skeleton) { if (!self || !skeleton) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "editor and skeleton must not be null", @@ -566,6 +1488,22 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return script::projectResult(vm, self->jumpRuntimeSection(static_cast(sectionIndex)), montageAdvanceValue); }); + actionEditor.addFunc( + "evaluateRuntimeSectionProgress", + [vm](ScriptActionTimelineEditor* self, int sectionIndex, float progress) { + if (!self || sectionIndex < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "runtime section index is invalid", "index"); + return script::projectResult( + vm, self->evaluateRuntimeSectionProgress(static_cast(sectionIndex), progress), + montageAdvanceValue); + }); + actionEditor.addFunc("replayRuntimeCrossFade", [vm](ScriptActionTimelineEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + return script::projectResult(vm, self->replayRuntimeCrossFade()); + }); actionEditor.addFunc( "syncRuntimeSection", [vm](ScriptActionTimelineEditor* self, int sectionIndex, float targetSeconds) { if (!self || sectionIndex < 0) @@ -575,6 +1513,53 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return script::projectResult( vm, self->syncRuntimeSection(static_cast(sectionIndex), std::move(target).takeValue())); }); + actionEditor.addFunc( + "syncRuntimeSectionAndJump", [vm](ScriptActionTimelineEditor* self, int sectionIndex, float targetSeconds) { + if (!self || sectionIndex < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "runtime section index is invalid", "index"); + auto target = seconds(targetSeconds); + if (!target) return script::projectStatusResult(vm, target.status(), false, false); + return script::projectResult( + vm, + self->syncRuntimeSectionAndJump(static_cast(sectionIndex), + std::move(target).takeValue()), + montageAdvanceValue); + }); + actionEditor.addFunc("clearRuntimeSectionSync", [vm](ScriptActionTimelineEditor* self, int sectionIndex) { + if (!self || sectionIndex < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "runtime section index is invalid", "index"); + return script::projectResult(vm, + self->clearRuntimeSectionSync(static_cast(sectionIndex))); + }); + actionEditor.addFunc("setRuntimeBoneMask", + [vm](ScriptActionTimelineEditor* self, animation::AnimBoneMask* mask) { + if (!self || !mask) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor and bone mask must not be null", "mask"); + return script::projectResult(vm, self->setRuntimeBoneMask(*mask)); + }); + actionEditor.addFunc("clearRuntimeBoneMask", [vm](ScriptActionTimelineEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + return script::projectResult(vm, self->clearRuntimeBoneMask()); + }); + actionEditor.addFunc("setRuntimeLayerWeight", [vm](ScriptActionTimelineEditor* self, float weight) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + return script::projectResult(vm, self->setRuntimeLayerWeight(weight)); + }); + actionEditor.addFunc("getRuntimeLayerWeight", + [](ScriptActionTimelineEditor* self) { return self ? self->runtimeLayerWeight() : 0.0f; }); + actionEditor.addFunc("setRuntimeLayerAdditive", [vm](ScriptActionTimelineEditor* self, bool additive) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); + return script::projectResult(vm, self->setRuntimeLayerAdditive(additive)); + }); + actionEditor.addFunc("getRuntimeLayerAdditive", + [](ScriptActionTimelineEditor* self) { return self && self->runtimeLayerAdditive(); }); actionEditor.addFunc("beginRuntimeBlendOut", [vm](ScriptActionTimelineEditor* self, float durationSeconds) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); @@ -586,8 +1571,24 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla actionEditor.addFunc("setRuntimePaused", [](ScriptActionTimelineEditor* self, bool paused) { if (self) self->setRuntimePaused(paused); }); + actionEditor.addFunc("setRuntimeBlockExternallyHandled", + [vm](ScriptActionTimelineEditor* self, const std::string& type, bool external) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + return script::projectResult(vm, self->setRuntimeBlockExternallyHandled(type, external)); + }); actionEditor.addFunc("isRuntimePaused", [](ScriptActionTimelineEditor* self) { return self && self->runtimePaused(); }); + actionEditor.addFunc("setRuntimeRate", [vm](ScriptActionTimelineEditor* self, float rate) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor must not be null"); + return script::projectResult(vm, self->setRuntimeRate(rate)); + }); + actionEditor.addFunc("getRuntimeRate", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->runtimeRate()) : 0.0f; + }); actionEditor.addFunc("cancelRuntime", [vm](ScriptActionTimelineEditor* self) { if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor must not be null"); @@ -595,8 +1596,23 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla }); actionEditor.addFunc("getRuntimePose", [](ScriptActionTimelineEditor* self) { return self ? self->runtimePose() : nullptr; }); + actionEditor.addFunc("getRuntimePoseOverBase", [](ScriptActionTimelineEditor* self, animation::AnimPose* basePose) { + return self && basePose ? self->runtimePoseOverBase(*basePose) : nullptr; + }); actionEditor.addFunc("isRuntimePlaying", [](ScriptActionTimelineEditor* self) { return self && self->runtimePlaying(); }); + actionEditor.addFunc("getRuntimeState", + [](ScriptActionTimelineEditor* self) { return self ? self->runtimeState() : "not-started"; }); + actionEditor.addFunc("getRuntimeElapsedSeconds", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->runtimeElapsedSeconds()) : 0.0f; + }); + actionEditor.addFunc("getRuntimeDurationSeconds", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->runtimeDurationSeconds()) : 0.0f; + }); + actionEditor.addFunc("getRuntimeLayer", + [](ScriptActionTimelineEditor* self) { return self ? self->runtimeLayer() : -1; }); + actionEditor.addFunc("getRuntimeSlot", + [](ScriptActionTimelineEditor* self) { return self ? self->runtimeSlot() : -1; }); actionEditor.addFunc("getRuntimeSectionId", [](ScriptActionTimelineEditor* self) { if (!self || !self->runtimeAdvance() || !self->runtimeAdvance()->sectionId) return std::string{}; return self->runtimeAdvance()->sectionId->format(); @@ -622,6 +1638,12 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return std::string{}; return eventKind(self->runtimeAdvance()->events[static_cast(index)].kind); }); + actionEditor.addFunc("getRuntimeEventId", [](ScriptActionTimelineEditor* self, int index) { + if (!self || !self->runtimeAdvance() || index < 0 || + static_cast(index) >= self->runtimeAdvance()->events.size()) + return std::string{}; + return self->runtimeAdvance()->events[static_cast(index)].itemId.format(); + }); actionEditor.addFunc("getRuntimeEventType", [](ScriptActionTimelineEditor* self, int index) { if (!self || !self->runtimeAdvance() || index < 0 || static_cast(index) >= self->runtimeAdvance()->events.size()) @@ -634,6 +1656,13 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla return 0.0f; return static_cast(self->runtimeAdvance()->events[static_cast(index)].time.seconds()); }); + actionEditor.addFunc("getRuntimeEventPayloadJson", [](ScriptActionTimelineEditor* self, int index) { + if (!self || !self->runtimeAdvance() || index < 0 || + static_cast(index) >= self->runtimeAdvance()->events.size()) + return std::string("{}"); + auto encoded = Value(self->runtimeAdvance()->events[static_cast(index)].payload).toJson(); + return encoded ? encoded.value() : std::string("{}"); + }); actionEditor.addFunc("getRuntimeActiveBlockCount", [](ScriptActionTimelineEditor* self) { return self && self->runtimeAdvance() ? static_cast(self->runtimeAdvance()->activeBlocks.size()) : 0; }); @@ -667,8 +1696,46 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla actionEditor.addFunc("pause", [](ScriptActionTimelineEditor* self) { if (self) self->editor().pause(); }); + actionEditor.addFunc("stop", [vm](ScriptActionTimelineEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + return project(vm, self->stopPreview()); + }); + actionEditor.addFunc("stepFrames", [vm](ScriptActionTimelineEditor* self, int frames, float frameRate) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + return project(vm, self->stepPreviewFrames(frames, frameRate)); + }); + actionEditor.addFunc("jumpToEnd", [vm](ScriptActionTimelineEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + return project(vm, self->jumpPreviewToEnd()); + }); actionEditor.addFunc("isPlaying", [](ScriptActionTimelineEditor* self) { return self && self->editor().playing(); }); + actionEditor.addFunc("isDocumentBacked", [](ScriptActionTimelineEditor* self) { + return self && !self->document().id.empty(); + }); + actionEditor.addFunc("isDirty", + [](ScriptActionTimelineEditor* self) { return self && self->documentDirty(); }); + actionEditor.addFunc("saveDocument", [vm](ScriptActionTimelineEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto saved = self->saveDocument(); + return project(vm, saved, + saved.ok() ? Value(static_cast(saved.value().revision.saved)) : Value{}); + }); + actionEditor.addFunc("reconcileDocument", [vm](ScriptActionTimelineEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto reconciled = self->reconcileDocument(); + return project(vm, reconciled, + reconciled.ok() ? Value(static_cast(reconciled.value().revision.edit)) : Value{}); + }); + actionEditor.addFunc("getDocumentId", [](ScriptActionTimelineEditor* self) { + return self ? self->document().id.value() : std::string{}; + }); + actionEditor.addFunc("getDocumentTitle", [](ScriptActionTimelineEditor* self) { + return self ? self->document().title : std::string{}; + }); + actionEditor.addFunc("getDocumentUri", [](ScriptActionTimelineEditor* self) { + return self ? self->document().resourceUri : std::string{}; + }); actionEditor.addFunc("canUndo", [](ScriptActionTimelineEditor* self) { return self && self->editor().canUndo(); }); actionEditor.addFunc("canRedo", [](ScriptActionTimelineEditor* self) { return self && self->editor().canRedo(); }); actionEditor.addFunc("isDragging", [](ScriptActionTimelineEditor* self) { @@ -683,9 +1750,96 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla actionEditor.addFunc("getRevision", [](ScriptActionTimelineEditor* self) { return self ? static_cast(self->editor().target().revision()) : std::int64_t{0}; }); + actionEditor.addFunc("setMontageSettings", [vm](ScriptActionTimelineEditor* self, float basePlayRate, + bool looping, bool footIk, int animationLayer, + float blendInSeconds, float blendOutSeconds, + float blendOutOffsetSeconds, bool rootHorizontal, + bool rootVertical, bool rootRotation) { + if (!self || animationLayer < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "montage editor and non-negative animation layer are required", "montage"); + auto blendIn = seconds(blendInSeconds); + auto blendOut = seconds(blendOutSeconds); + auto blendOutOffset = seconds(blendOutOffsetSeconds); + if (!blendIn) return script::projectStatusResult(vm, blendIn.status(), false, false); + if (!blendOut) return script::projectStatusResult(vm, blendOut.status(), false, false); + if (!blendOutOffset) return script::projectStatusResult(vm, blendOutOffset.status(), false, false); + action::ActionMontageSettings settings; + settings.basePlayRate = basePlayRate; + settings.looping = looping; + settings.footIk = footIk; + settings.animationLayer = static_cast(animationLayer); + settings.defaultBlendIn = blendIn.value(); + settings.defaultBlendOut = blendOut.value(); + settings.blendOutOffset = blendOutOffset.value(); + settings.rootMotionHorizontal = rootHorizontal; + settings.rootMotionVertical = rootVertical; + settings.rootMotionRotation = rootRotation; + return project(vm, self->setMontageSettings(std::move(settings))); + }); + actionEditor.addFunc("getMontageBasePlayRate", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().montage.basePlayRate) : 1.0f; + }); + actionEditor.addFunc("getMontageLooping", [](ScriptActionTimelineEditor* self) { + return self && self->editor().target().timeline().montage.looping; + }); + actionEditor.addFunc("getMontageFootIk", [](ScriptActionTimelineEditor* self) { + return self && self->editor().target().timeline().montage.footIk; + }); + actionEditor.addFunc("getMontageAnimationLayer", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().montage.animationLayer) : 0; + }); + actionEditor.addFunc("getMontageBlendIn", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().montage.defaultBlendIn.seconds()) : 0.0f; + }); + actionEditor.addFunc("getMontageBlendOut", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().montage.defaultBlendOut.seconds()) : 0.0f; + }); + actionEditor.addFunc("getMontageBlendOutOffset", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().montage.blendOutOffset.seconds()) : 0.0f; + }); + actionEditor.addFunc("getMontageRootMotionHorizontal", [](ScriptActionTimelineEditor* self) { + return self && self->editor().target().timeline().montage.rootMotionHorizontal; + }); + actionEditor.addFunc("getMontageRootMotionVertical", [](ScriptActionTimelineEditor* self) { + return self && self->editor().target().timeline().montage.rootMotionVertical; + }); + actionEditor.addFunc("getMontageRootMotionRotation", [](ScriptActionTimelineEditor* self) { + return self && self->editor().target().timeline().montage.rootMotionRotation; + }); actionEditor.addFunc("getAnimationUri", [](ScriptActionTimelineEditor* self) { return self ? self->editor().target().timeline().animationUri : std::string{}; }); + actionEditor.addFunc("getSectionSplitCount", [](ScriptActionTimelineEditor* self) { + return self ? static_cast(self->editor().target().timeline().splitTimestamps.size()) : 0; + }); + actionEditor.addFunc("getSectionSplitTime", [](ScriptActionTimelineEditor* self, int index) { + if (!self || index < 0 || static_cast(index) >= + self->editor().target().timeline().splitTimestamps.size()) + return 0.0f; + return static_cast( + self->editor().target().timeline().splitTimestamps[static_cast(index)].seconds()); + }); + actionEditor.addFunc("addSectionSplit", [vm](ScriptActionTimelineEditor* self, float timeSeconds) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline editor is null"); + auto time = seconds(timeSeconds); + if (!time) return script::projectStatusResult(vm, time.status(), false, false); + return project(vm, self->addSectionSplit(time.value())); + }); + actionEditor.addFunc("setSectionSplit", [vm](ScriptActionTimelineEditor* self, int index, float timeSeconds) { + if (!self || index < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor and non-negative split index are required"); + auto time = seconds(timeSeconds); + if (!time) return script::projectStatusResult(vm, time.status(), false, false); + return project(vm, self->setSectionSplit(static_cast(index), time.value())); + }); + actionEditor.addFunc("removeSectionSplit", [vm](ScriptActionTimelineEditor* self, int index) { + if (!self || index < 0) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "action timeline editor and non-negative split index are required"); + return project(vm, self->removeSectionSplit(static_cast(index))); + }); actionEditor.addFunc("getAnimationSectionCount", [](ScriptActionTimelineEditor* self) { return self ? static_cast(self->editor().target().timeline().animationSections.size()) : 0; }); @@ -803,6 +1957,32 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla const auto* track = self ? trackAt(self->editor().target().timeline(), index) : nullptr; return track && track->muted; }); + actionEditor.addFunc("getTrackLocked", [](ScriptActionTimelineEditor* self, int index) { + const auto* track = self ? trackAt(self->editor().target().timeline(), index) : nullptr; + return track && track->locked; + }); + actionEditor.addFunc("getInsertableTypeCount", [](ScriptActionTimelineEditor* self, bool state) { + if (!self) return 0; + return static_cast(self->widget() + .insertableTypes(state ? action::ActionNotifyShape::State + : action::ActionNotifyShape::Instant) + .size()); + }); + actionEditor.addFunc("getInsertableType", [](ScriptActionTimelineEditor* self, bool state, int index) { + if (!self || index < 0) return std::string{}; + const auto types = self->widget().insertableTypes(state ? action::ActionNotifyShape::State + : action::ActionNotifyShape::Instant); + return static_cast(index) < types.size() ? types[static_cast(index)].type + : std::string{}; + }); + actionEditor.addFunc("getInsertableTypeLabel", [](ScriptActionTimelineEditor* self, bool state, int index) { + if (!self || index < 0) return std::string{}; + const auto types = self->widget().insertableTypes(state ? action::ActionNotifyShape::State + : action::ActionNotifyShape::Instant); + if (static_cast(index) >= types.size()) return std::string{}; + const auto& descriptor = types[static_cast(index)]; + return descriptor.category + " / " + descriptor.displayName; + }); actionEditor.addFunc("getLayoutWidth", [](ScriptActionTimelineEditor* self) { return self ? self->widget().layout().width : 0.0f; }); @@ -838,6 +2018,21 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla const auto* item = itemAt(layout, index); return item ? item->type.format() : std::string{}; }); + actionEditor.addFunc("getItemVisual", [](ScriptActionTimelineEditor* self, int index) { + const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{}; + const auto* item = itemAt(layout, index); + return item ? std::string(timelineItemVisualName(item->visual)) : std::string("custom"); + }); + actionEditor.addFunc("getItemLabel", [](ScriptActionTimelineEditor* self, int index) { + const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{}; + const auto* item = itemAt(layout, index); + return item ? item->displayName : std::string{}; + }); + actionEditor.addFunc("getItemDetail", [](ScriptActionTimelineEditor* self, int index) { + const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{}; + const auto* item = itemAt(layout, index); + return item ? item->detail : std::string{}; + }); actionEditor.addFunc("getItemState", [](ScriptActionTimelineEditor* self, int index) { const auto layout = self ? self->widget().layout() : TimelineWidgetLayout{}; const auto* item = itemAt(layout, index); @@ -877,6 +2072,177 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; return state ? static_cast(state->end.seconds()) : 0.0f; }); + actionEditor.addFunc("getItemStart", [](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) return 0.0f; + const auto& timeline = self->editor().target().timeline(); + if (const auto* section = findSection(timeline, itemId)) return static_cast(section->start.seconds()); + if (const auto* state = findState(timeline, itemId)) return static_cast(state->start.seconds()); + if (const auto* notify = findNotify(timeline, itemId)) return static_cast(notify->time.seconds()); + return 0.0f; + }); + actionEditor.addFunc("getItemEnd", [](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) return 0.0f; + const auto& timeline = self->editor().target().timeline(); + if (const auto* section = findSection(timeline, itemId)) return static_cast(section->end.seconds()); + if (const auto* state = findState(timeline, itemId)) return static_cast(state->end.seconds()); + if (const auto* notify = findNotify(timeline, itemId)) return static_cast(notify->time.seconds()); + return 0.0f; + }); + actionEditor.addFunc("getItemEnabled", [](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) return false; + const auto& timeline = self->editor().target().timeline(); + if (findSection(timeline, itemId)) return true; + if (const auto* state = findState(timeline, itemId)) return state->enabled; + if (const auto* notify = findNotify(timeline, itemId)) return notify->enabled; + return false; + }); + actionEditor.addFunc("getItemPayloadJson", [](ScriptActionTimelineEditor* self, const std::string& itemId) { + if (!self) return std::string("{}"); + const auto& timeline = self->editor().target().timeline(); + const Value::Object* payload = nullptr; + if (const auto* state = findState(timeline, itemId)) payload = &state->payload; + if (const auto* notify = findNotify(timeline, itemId)) payload = ¬ify->payload; + if (!payload) return std::string("{}"); + auto encoded = Value(*payload).toJson(); + return encoded ? encoded.value() : std::string("{}"); + }); + actionEditor.addFunc("patchItemPayload", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& fieldsJson) { + auto parsed = Value::fromJson(fieldsJson); + if (!parsed) return script::projectStatusResult(vm, parsed.status(), false, false); + const auto* fields = parsed.value().getIf(); + if (!fields) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "payload patch must be a JSON object", + "fieldsJson"); + return project(vm, patchItemPayload(self, itemId, *fields)); + }); + actionEditor.addFunc("setItemPayloadText", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, const std::string& value) { + return project(vm, patchItemPayload(self, itemId, {{field, value}})); + }); + actionEditor.addFunc("setItemPayloadNumber", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, float value) { + return project(vm, patchItemPayload(self, itemId, {{field, static_cast(value)}})); + }); + actionEditor.addFunc("setItemPayloadInteger", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, int value) { + return project(vm, patchItemPayload(self, itemId, {{field, static_cast(value)}})); + }); + actionEditor.addFunc("setItemPayloadBool", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, bool value) { + return project(vm, patchItemPayload(self, itemId, {{field, value}})); + }); + actionEditor.addFunc("setItemPayloadVector3", [vm](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, float x, float y, float z) { + return project(vm, patchItemPayload(self, itemId, + {{field, Value::Array{static_cast(x), static_cast(y), + static_cast(z)}}})); + }); + actionEditor.addFunc("setItemPayloadTextList", [vm](ScriptActionTimelineEditor* self, + const std::string& itemId, const std::string& field, + const std::string& delimitedValues) { + Value::Array values; + std::size_t cursor = 0; + while (cursor <= delimitedValues.size()) { + const auto separator = delimitedValues.find(';', cursor); + auto value = delimitedValues.substr(cursor, separator == std::string::npos + ? std::string::npos + : separator - cursor); + const auto first = value.find_first_not_of(" \t\r\n"); + if (first != std::string::npos) { + const auto last = value.find_last_not_of(" \t\r\n"); + values.emplace_back(value.substr(first, last - first + 1)); + } + if (separator == std::string::npos) break; + cursor = separator + 1; + } + return project(vm, patchItemPayload(self, itemId, {{field, std::move(values)}})); + }); + actionEditor.addFunc("getItemPayloadText", [](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, const std::string& fallback) { + const auto value = itemPayloadField(self, itemId, field); + const auto* text = value ? value->getIf() : nullptr; + return text ? *text : fallback; + }); + actionEditor.addFunc("getItemPayloadNumber", [](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, float fallback) { + const auto value = itemPayloadField(self, itemId, field); + if (!value) return fallback; + if (const auto* decimal = value->getIf()) return static_cast(*decimal); + if (const auto* integer = value->getIf()) return static_cast(*integer); + return fallback; + }); + actionEditor.addFunc("getItemPayloadBool", [](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, bool fallback) { + const auto value = itemPayloadField(self, itemId, field); + const auto* boolean = value ? value->getIf() : nullptr; + return boolean ? *boolean : fallback; + }); + actionEditor.addFunc("getItemPayloadTextList", [](ScriptActionTimelineEditor* self, + const std::string& itemId, const std::string& field) { + const auto value = itemPayloadField(self, itemId, field); + const auto* array = value ? value->getIf() : nullptr; + if (!array) return std::string{}; + std::string result; + for (const auto& entry : *array) { + const auto* text = entry.getIf(); + if (!text) return std::string{}; + if (!result.empty()) result += "; "; + result += *text; + } + return result; + }); + actionEditor.addFunc("getItemPayloadVector", [](ScriptActionTimelineEditor* self, const std::string& itemId, + const std::string& field, int component, float fallback) { + const auto value = itemPayloadField(self, itemId, field); + const auto* array = value ? value->getIf() : nullptr; + if (!array || component < 0 || component >= static_cast(array->size())) return fallback; + const auto& entry = (*array)[static_cast(component)]; + if (const auto* decimal = entry.getIf()) return static_cast(*decimal); + if (const auto* integer = entry.getIf()) return static_cast(*integer); + return fallback; + }); + actionEditor.addFunc("getParameterKeyCount", [](ScriptActionTimelineEditor* self, const std::string& itemId) { + const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; + if (!state || state->type.format() != "presentation:parameter-curve") return 0; + auto binding = action::ActionParameterCurveBinding::fromPayload(state->payload); + return binding ? static_cast(binding.value().keys.size()) : 0; + }); + actionEditor.addFunc("getParameterKeyTime", [](ScriptActionTimelineEditor* self, + const std::string& itemId, int index) { + const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; + if (!state || index < 0) return 0.0f; + auto binding = action::ActionParameterCurveBinding::fromPayload(state->payload); + return binding && static_cast(index) < binding.value().keys.size() + ? static_cast(binding.value().keys[static_cast(index)].time) + : 0.0f; + }); + actionEditor.addFunc("getParameterKeyValue", [](ScriptActionTimelineEditor* self, + const std::string& itemId, int index) { + const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; + if (!state || index < 0) return 0.0f; + auto binding = action::ActionParameterCurveBinding::fromPayload(state->payload); + return binding && static_cast(index) < binding.value().keys.size() + ? static_cast(binding.value().keys[static_cast(index)].value) + : 0.0f; + }); + actionEditor.addFunc("getParameterKeyInterpolation", [](ScriptActionTimelineEditor* self, + const std::string& itemId, int index) { + const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; + if (!state || index < 0) return std::string{}; + auto binding = action::ActionParameterCurveBinding::fromPayload(state->payload); + return binding && static_cast(index) < binding.value().keys.size() + ? std::string(action::actionParameterInterpolationName( + binding.value().keys[static_cast(index)].interpolation)) + : std::string{}; + }); + actionEditor.addFunc("sampleParameterCurve", [](ScriptActionTimelineEditor* self, + const std::string& itemId, float progress) { + const auto* state = self ? findState(self->editor().target().timeline(), itemId) : nullptr; + if (!state || !std::isfinite(progress)) return 0.0f; + auto binding = action::ActionParameterCurveBinding::fromPayload(state->payload); + return binding ? static_cast(binding.value().sample(progress)) : 0.0f; + }); actionEditor.addFunc("getEventCount", [](ScriptActionTimelineEditor* self) { return self ? static_cast(self->editor().previewEvents().size()) : 0; @@ -910,8 +2276,21 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla : std::string{}; }); - moduleClass.addFunc("create", [vm](eve::action_editor::ActionEditorModule*, const std::string& targetId, - const ssq::Object& timelineObject) { + moduleClass.addFunc("createAssetCatalog", [vm](eve::action_editor::ActionEditorModule*, + const std::string& projectRoot) { + if (projectRoot.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "project root must not be empty"); + auto object = script::makeOwnedSquirrelInstance( + vm, std::make_unique(std::filesystem::path(projectRoot))); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); + moduleClass.addFunc("create", [vm, clipboard](eve::action_editor::ActionEditorModule*, const std::string& targetId, + const ssq::Object& timelineObject) { if (targetId.empty()) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "action timeline target id must not be empty", "targetId"); @@ -925,7 +2304,7 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla if (!registry) return script::projectStatusResult(vm, registry.status(), false, false); auto object = script::makeOwnedSquirrelInstance( vm, std::make_unique(targetId, std::move(timeline).takeValue(), - std::move(registry).takeValue())); + std::move(registry).takeValue(), clipboard)); if (!object) return script::projectStatusResult(vm, object.status(), false, false); ssq::Object owned = std::move(object).takeValue(); auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); @@ -933,6 +2312,46 @@ void exposeActionTimelineScriptBindings(ssq::Table& table, ssq::Class& moduleCla result.set("ownership", std::string("owned")); return result; }); + moduleClass.addFunc( + "openDocument", + [vm, clipboard](eve::action_editor::ActionEditorModule*, const std::string& projectRoot, + const std::string& assetGuid, const std::string& title, const std::string& resourceUri, + const ssq::Object& initialTimelineObject) { + if (projectRoot.empty() || assetGuid.empty() || resourceUri.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "project root, asset guid and resource URI are required"); + script::SquirrelValueOptions options; + options.source = kBindingSource; + auto value = script::valueFromSquirrel(initialTimelineObject, options); + if (!value) return script::projectStatusResult(vm, value.status(), false, false); + auto initialTimeline = action::ActionTimeline::fromValue(value.value()); + if (!initialTimeline) + return script::projectStatusResult(vm, initialTimeline.status(), false, false); + auto initialValue = initialTimeline.value().toValue(); + if (!initialValue) return script::projectStatusResult(vm, initialValue.status(), false, false); + + auto store = std::make_unique(std::filesystem::path(projectRoot)); + auto documents = std::make_unique(store.get()); + auto opened = documents->open({DocumentKind::Timeline, AssetGuid(assetGuid)}, title, resourceUri, + toEditorValue(initialValue.value())); + if (!opened.ok()) return script::projectStatusResult(vm, opened.status(), false, false); + auto content = documents->content(opened.value().id); + if (!content.ok()) return script::projectStatusResult(vm, content.status(), false, false); + auto timeline = action::ActionTimeline::fromValue(toPresentationValue(content.value())); + if (!timeline) return script::projectStatusResult(vm, timeline.status(), false, false); + auto registry = action::ActionNotifyRegistry::withBuiltins(); + if (!registry) return script::projectStatusResult(vm, registry.status(), false, false); + auto instance = std::make_unique( + assetGuid, std::move(timeline).takeValue(), std::move(registry).takeValue(), clipboard); + instance->attachDocument(std::move(store), std::move(documents), opened.value()); + auto object = script::makeOwnedSquirrelInstance(vm, std::move(instance)); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); } } // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelineSelection.cpp b/src/modules/action/editor/ActionTimelineSelection.cpp new file mode 100644 index 00000000..212921b7 --- /dev/null +++ b/src/modules/action/editor/ActionTimelineSelection.cpp @@ -0,0 +1,241 @@ +#include "action/editor/ActionTimelineEditor.h" + +#include +#include +#include + +namespace eve::editor { +namespace { + +void sortTrack(action::ActionTrack& track) { + std::stable_sort(track.notifies.begin(), track.notifies.end(), [](const auto& left, const auto& right) { + return left.time == right.time ? left.id.format() < right.id.format() : left.time < right.time; + }); + std::stable_sort(track.states.begin(), track.states.end(), [](const auto& left, const auto& right) { + return left.start == right.start ? left.id.format() < right.id.format() : left.start < right.start; + }); +} + +void sortAnimationSections(action::ActionTimeline& timeline) { + std::stable_sort( + timeline.animationSections.begin(), timeline.animationSections.end(), [](const auto& left, const auto& right) { + return left.start == right.start ? left.id.format() < right.id.format() : left.start < right.start; + }); +} + +} // namespace + +EditorResult ActionTimelineEditor::selectionRange() const { + std::optional range; + const auto include = [&](Duration start, Duration end) { + if (!range) { + range = TimelineSelectionRange{start, end}; + return; + } + range->start = std::min(range->start, start); + range->end = std::max(range->end, end); + }; + for (const auto& section : target_.timeline().animationSections) + if (selection_.contains(section.id.format())) include(section.start, section.end); + for (const auto& track : target_.timeline().tracks) { + for (const auto& notify : track.notifies) + if (selection_.contains(notify.id.format())) include(notify.time, notify.time); + for (const auto& state : track.states) + if (selection_.contains(state.id.format())) include(state.start, state.end); + } + if (!range) + return eve::editing::failed(EditorStatus::Rejected, + RuleId("editor.action.timeline.selection-empty"), + "No timeline items are selected"); + return eve::editing::applied(*range); +} + +EditorResult ActionTimelineEditor::moveSelection(Duration delta) { + if (selection_.empty()) return rejected("editor.action.timeline.selection-empty", "No timeline items are selected"); + action::ActionTimeline candidate = target_.timeline(); + const auto move = [&](Duration value) { return value.tryAdd(delta); }; + for (auto& section : candidate.animationSections) { + if (!selection_.contains(section.id.format())) continue; + auto start = move(section.start); + auto end = move(section.end); + if (!start || !end) + return rejected("editor.action.timeline.time-overflow", "Selected animation-section time overflowed"); + section.start = start.value(); + section.end = end.value(); + } + for (auto& track : candidate.tracks) { + const bool selectedNotify = std::any_of(track.notifies.begin(), track.notifies.end(), + [&](const auto& item) { return selection_.contains(item.id.format()); }); + const bool selectedState = std::any_of(track.states.begin(), track.states.end(), + [&](const auto& item) { return selection_.contains(item.id.format()); }); + if (track.locked && (selectedNotify || selectedState)) + return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track"); + for (auto& notify : track.notifies) { + if (!selection_.contains(notify.id.format())) continue; + auto time = move(notify.time); + if (!time) return rejected("editor.action.timeline.time-overflow", "Selected notify time overflowed"); + notify.time = time.value(); + } + for (auto& state : track.states) { + if (!selection_.contains(state.id.format())) continue; + auto start = move(state.start); + auto end = move(state.end); + if (!start || !end) + return rejected("editor.action.timeline.time-overflow", "Selected notify-state time overflowed"); + state.start = start.value(); + state.end = end.value(); + } + sortTrack(track); + } + sortAnimationSections(candidate); + return commit(std::move(candidate), "Move action timeline selection", "action.timeline.selection.move"); +} + +EditorResult ActionTimelineEditor::alignSelectionStart() { + auto range = selectionRange(); + if (!range.ok()) return EditorResult::failure(range.status()); + action::ActionTimeline candidate = target_.timeline(); + const Duration anchor = range.value().start; + for (auto& section : candidate.animationSections) { + if (!selection_.contains(section.id.format())) continue; + const Duration span = Duration::fromNanoseconds(section.end.nanoseconds() - section.start.nanoseconds()); + auto end = anchor.tryAdd(span); + if (!end) + return rejected("editor.action.timeline.time-overflow", "Aligned animation-section time overflowed"); + section.start = anchor; + section.end = end.value(); + } + for (auto& track : candidate.tracks) { + const bool touchesLocked = track.locked && + (std::any_of(track.notifies.begin(), track.notifies.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + }) || + std::any_of(track.states.begin(), track.states.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + })); + if (touchesLocked) + return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track"); + for (auto& notify : track.notifies) + if (selection_.contains(notify.id.format())) notify.time = anchor; + for (auto& state : track.states) { + if (!selection_.contains(state.id.format())) continue; + const Duration span = Duration::fromNanoseconds(state.end.nanoseconds() - state.start.nanoseconds()); + auto end = anchor.tryAdd(span); + if (!end) + return rejected("editor.action.timeline.time-overflow", "Aligned notify-state time overflowed"); + state.start = anchor; + state.end = end.value(); + } + sortTrack(track); + } + sortAnimationSections(candidate); + return commit(std::move(candidate), "Align action timeline selection starts", + "action.timeline.selection.align-start"); +} + +EditorResult ActionTimelineEditor::alignSelectionEnd() { + auto range = selectionRange(); + if (!range.ok()) return EditorResult::failure(range.status()); + action::ActionTimeline candidate = target_.timeline(); + const Duration anchor = range.value().end; + for (auto& section : candidate.animationSections) { + if (!selection_.contains(section.id.format())) continue; + const auto span = section.end.nanoseconds() - section.start.nanoseconds(); + auto start = anchor.tryAdd(Duration::fromNanoseconds(-span)); + if (!start) + return rejected("editor.action.timeline.time-overflow", "Aligned animation-section time overflowed"); + section.start = start.value(); + section.end = anchor; + } + for (auto& track : candidate.tracks) { + const bool touchesLocked = track.locked && + (std::any_of(track.notifies.begin(), track.notifies.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + }) || + std::any_of(track.states.begin(), track.states.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + })); + if (touchesLocked) + return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track"); + for (auto& notify : track.notifies) + if (selection_.contains(notify.id.format())) notify.time = anchor; + for (auto& state : track.states) { + if (!selection_.contains(state.id.format())) continue; + const auto span = state.end.nanoseconds() - state.start.nanoseconds(); + auto start = anchor.tryAdd(Duration::fromNanoseconds(-span)); + if (!start) + return rejected("editor.action.timeline.time-overflow", "Aligned notify-state time overflowed"); + state.start = start.value(); + state.end = anchor; + } + sortTrack(track); + } + sortAnimationSections(candidate); + return commit(std::move(candidate), "Align action timeline selection ends", + "action.timeline.selection.align-end"); +} + +EditorResult ActionTimelineEditor::scaleSelection(Duration start, Duration end) { + auto range = selectionRange(); + if (!range.ok()) return EditorResult::failure(range.status()); + if (start < Duration::zero() || end <= start || end > target_.timeline().duration) + return rejected("editor.action.timeline.selection-scale-range", + "Scaled selection range must be ordered and inside the timeline"); + const auto oldSpan = range.value().end.nanoseconds() - range.value().start.nanoseconds(); + if (oldSpan <= 0) + return rejected("editor.action.timeline.selection-scale-point", + "A point-only selection cannot be proportionally scaled"); + const double rate = static_cast(end.nanoseconds() - start.nanoseconds()) / + static_cast(oldSpan); + const auto map = [&](Duration value) -> Result { + const Duration offset = Duration::fromNanoseconds(value.nanoseconds() - range.value().start.nanoseconds()); + auto scaled = offset.scaled(rate); + if (!scaled) return scaled; + return start.tryAdd(scaled.value()); + }; + action::ActionTimeline candidate = target_.timeline(); + for (auto& section : candidate.animationSections) { + if (!selection_.contains(section.id.format())) continue; + auto mappedStart = map(section.start); + auto mappedEnd = map(section.end); + auto blend = section.blendIn.scaled(rate); + if (!mappedStart || !mappedEnd || !blend) + return rejected("editor.action.timeline.selection-scale-overflow", "Scaled selection time overflowed"); + section.start = mappedStart.value(); + section.end = mappedEnd.value(); + section.blendIn = blend.value(); + } + for (auto& track : candidate.tracks) { + const bool touchesLocked = track.locked && + (std::any_of(track.notifies.begin(), track.notifies.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + }) || + std::any_of(track.states.begin(), track.states.end(), [&](const auto& item) { + return selection_.contains(item.id.format()); + })); + if (touchesLocked) + return rejected("editor.action.timeline.track-locked", "Selection contains an item on a locked track"); + for (auto& notify : track.notifies) { + if (!selection_.contains(notify.id.format())) continue; + auto mapped = map(notify.time); + if (!mapped) + return rejected("editor.action.timeline.selection-scale-overflow", "Scaled notify time overflowed"); + notify.time = mapped.value(); + } + for (auto& state : track.states) { + if (!selection_.contains(state.id.format())) continue; + auto mappedStart = map(state.start); + auto mappedEnd = map(state.end); + if (!mappedStart || !mappedEnd) + return rejected("editor.action.timeline.selection-scale-overflow", + "Scaled notify-state time overflowed"); + state.start = mappedStart.value(); + state.end = mappedEnd.value(); + } + sortTrack(track); + } + sortAnimationSections(candidate); + return commit(std::move(candidate), "Scale action timeline selection", "action.timeline.selection.scale"); +} + +} // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelineSettings.cpp b/src/modules/action/editor/ActionTimelineSettings.cpp new file mode 100644 index 00000000..a05133ab --- /dev/null +++ b/src/modules/action/editor/ActionTimelineSettings.cpp @@ -0,0 +1,12 @@ +#include "action/editor/ActionTimelineEditor.h" + +namespace eve::editor { + +EditorResult ActionTimelineEditor::setMontageSettings(action::ActionMontageSettings settings) { + action::ActionTimeline candidate = target_.timeline(); + if (candidate.montage == settings) return eve::editing::noOp(); + candidate.montage = std::move(settings); + return commit(std::move(candidate), "Edit montage settings", "action.timeline.montage.settings"); +} + +} // namespace eve::editor diff --git a/src/modules/action/editor/ActionTimelineWidget.cpp b/src/modules/action/editor/ActionTimelineWidget.cpp index 8193150f..5341d445 100644 --- a/src/modules/action/editor/ActionTimelineWidget.cpp +++ b/src/modules/action/editor/ActionTimelineWidget.cpp @@ -1,8 +1,14 @@ #include "action/editor/ActionTimelineWidget.h" +#include "action/ActionAudioBlock.h" +#include "action/ActionAudioWaveform.h" +#include "action/ActionParameterCurve.h" +#include "common/Capability.h" + #include #include #include +#include #include namespace eve::editor { @@ -10,6 +16,8 @@ namespace { constexpr float kHandleRadius = 6.0f; constexpr float kNotifyWidth = 8.0f; +constexpr std::size_t kMaximumWaveformBuckets = 512; +constexpr std::size_t kMaximumCurveSegments = 128; EditorResult widgetError(std::string rule, std::string message, EditorStatus status = EditorStatus::Rejected) { return eve::editing::failed(status, RuleId(std::move(rule)), std::move(message)); @@ -64,8 +72,95 @@ EditorResult adapt(const EditorResult& result, std::string ru return eve::editing::failed(result.code(), RuleId(std::move(rule)), std::move(message)); } +std::string payloadText(const Value::Object& payload, std::string_view fieldName) { + const auto found = payload.find(std::string(fieldName)); + return found != payload.end() && found->second.isString() ? found->second.asString() : std::string{}; +} + +std::string resourceName(std::string value) { + const auto slash = value.find_last_of("/\\"); + return slash == std::string::npos ? value : value.substr(slash + 1); +} + +TimelineItemVisual visualFor(std::string_view type) { + if (type == "animation:section") return TimelineItemVisual::Animation; + if (type == "combat:damage") return TimelineItemVisual::Damage; + if (type == "presentation:vfx" || type == "presentation:vfx-state") return TimelineItemVisual::Vfx; + if (type == "presentation:audio" || type == "presentation:audio-state") return TimelineItemVisual::Audio; + if (type == "gameplay:prefab-spawn") return TimelineItemVisual::Prefab; + if (type == "presentation:camera") return TimelineItemVisual::Camera; + if (type == "combat:hitbox-window") return TimelineItemVisual::Hitbox; + if (type == "combat:invulnerability-window") return TimelineItemVisual::Defense; + if (type == "input:combo-window") return TimelineItemVisual::Input; + if (type == "collision:ignore-window") return TimelineItemVisual::Collision; + if (type == "movement:root-motion-window") return TimelineItemVisual::Movement; + if (type == "presentation:parameter-curve") return TimelineItemVisual::Curve; + if (type.starts_with("gameplay:")) return TimelineItemVisual::Gameplay; + return TimelineItemVisual::Custom; +} + +std::string detailFor(std::string_view type, const Value::Object& payload) { + if (type == "animation:section") return resourceName(payloadText(payload, "animationUri")); + if (type == "combat:damage") { + const auto amount = payload.find("amount"); + if (amount != payload.end() && (amount->second.isInt64() || amount->second.isDouble())) { + std::ostringstream stream; + if (amount->second.isInt64()) + stream << amount->second.asInt(); + else + stream << amount->second.asDouble(); + const auto damageType = payloadText(payload, "damageType"); + return damageType.empty() ? stream.str() : damageType + " " + stream.str(); + } + } + if (type == "presentation:vfx" || type == "presentation:vfx-state" || + type == "presentation:audio" || type == "presentation:audio-state" || + type == "gameplay:prefab-spawn") + return resourceName(payloadText(payload, "uri")); + if (type == "presentation:camera") return payloadText(payload, "cue"); + if (type == "combat:hitbox-window") return payloadText(payload, "hitbox"); + if (type == "input:combo-window") return payloadText(payload, "input"); + if (type == "collision:ignore-window") return payloadText(payload, "channel"); + if (type == "movement:root-motion-window") return payloadText(payload, "mode"); + if (type == "presentation:parameter-curve") return payloadText(payload, "target"); + if (type == "gameplay:event") return payloadText(payload, "tag"); + return {}; +} + +void decorate(TimelineItemGeometry& geometry, const LogicalId& type, const Value::Object& payload, + const action::ActionNotifyRegistry& registry) { + geometry.visual = visualFor(type.format()); + geometry.detail = detailFor(type.format(), payload); + if (geometry.visual == TimelineItemVisual::Animation) { + geometry.displayName = "Animation"; + return; + } + auto descriptor = registry.descriptor(type.format()); + geometry.displayName = descriptor ? descriptor.value().displayName : type.format(); +} + } // namespace +std::string_view timelineItemVisualName(TimelineItemVisual visual) noexcept { + switch (visual) { + case TimelineItemVisual::Animation: return "animation"; + case TimelineItemVisual::Gameplay: return "gameplay"; + case TimelineItemVisual::Damage: return "damage"; + case TimelineItemVisual::Vfx: return "vfx"; + case TimelineItemVisual::Audio: return "audio"; + case TimelineItemVisual::Prefab: return "prefab"; + case TimelineItemVisual::Camera: return "camera"; + case TimelineItemVisual::Hitbox: return "hitbox"; + case TimelineItemVisual::Defense: return "defense"; + case TimelineItemVisual::Input: return "input"; + case TimelineItemVisual::Collision: return "collision"; + case TimelineItemVisual::Movement: return "movement"; + case TimelineItemVisual::Curve: return "curve"; + case TimelineItemVisual::Custom: return "custom"; + } + return "custom"; +} + ActionTimelineWidget::ActionTimelineWidget(ActionTimelineEditor& editor, const action::ActionNotifyRegistry& registry) : editor_(editor), registry_(registry) {} @@ -154,9 +249,55 @@ Duration ActionTimelineWidget::xToTime(float x) const noexcept { return Duration::fromNanoseconds(time); } +std::optional ActionTimelineWidget::magneticSnap(Duration candidate, + const LogicalId& excludedItem) const noexcept { + constexpr double kSnapPixels = 8.0; + const auto& timeline = editor_.target().timeline(); + const auto start = visibleEnd_ > visibleStart_ ? visibleStart_ : Duration::zero(); + const auto end = visibleEnd_ > visibleStart_ ? visibleEnd_ : timeline.duration; + const auto selected = editor_.selectedItemIds(); + const auto excluded = [&](const LogicalId& id) { + return id == excludedItem || std::find(selected.begin(), selected.end(), id) != selected.end(); + }; + const auto pixels = static_cast(width_ - labelWidth_); + if (pixels <= 0.0) return std::nullopt; + const auto threshold = static_cast( + std::llround(static_cast(end.nanoseconds() - start.nanoseconds()) * kSnapPixels / pixels)); + std::optional best; + auto bestDelta = threshold + 1; + const auto consider = [&](Duration target) { + const auto delta = target > candidate ? target.nanoseconds() - candidate.nanoseconds() + : candidate.nanoseconds() - target.nanoseconds(); + if (delta <= threshold && delta < bestDelta) { + best = target; + bestDelta = delta; + } + }; + consider(Duration::zero()); + consider(editor_.previewTime()); + for (const auto split : timeline.splitTimestamps) consider(split); + for (const auto& section : timeline.animationSections) { + if (excluded(section.id)) continue; + consider(section.start); + consider(section.end); + } + for (const auto& track : timeline.tracks) { + for (const auto& notify : track.notifies) { + if (!excluded(notify.id)) consider(notify.time); + } + for (const auto& state : track.states) { + if (excluded(state.id)) continue; + consider(state.start); + consider(state.end); + } + } + return best; +} + TimelineWidgetLayout ActionTimelineWidget::layout() const { TimelineWidgetLayout result; result.width = width_; + result.audioWaveformsAvailable = eve::cap::query() != nullptr; const bool hasAnimationLane = !editor_.target().timeline().animationSections.empty(); const std::size_t rowOffset = hasAnimationLane ? 1U : 0U; result.height = rowHeight_ * static_cast(editor_.target().timeline().tracks.size() + rowOffset); @@ -191,6 +332,8 @@ TimelineWidgetLayout ActionTimelineWidget::layout() const { result.items.push_back({animationTrack, section.id, animationType, true, isSelected(section.id), timeToX(start), std::max(timeToX(end), timeToX(start) + 4.0f), 3.0f, rowHeight_ - 3.0f}); + decorate(result.items.back(), animationType, + {{"animationUri", Value(section.animationUri)}}, registry_); } } for (std::size_t row = 0; row < editor_.target().timeline().tracks.size(); ++row) { @@ -203,6 +346,7 @@ TimelineWidgetLayout ActionTimelineWidget::layout() const { result.items.push_back({track.id, notify.id, notify.type, false, isSelected(notify.id), center - kNotifyWidth * 0.5f, center + kNotifyWidth * 0.5f, top + 3.0f, top + rowHeight_ - 3.0f}); + decorate(result.items.back(), notify.type, notify.payload, registry_); } for (const auto& state : track.states) { Duration start = state.start; @@ -215,6 +359,7 @@ TimelineWidgetLayout ActionTimelineWidget::layout() const { const float maximum = std::max(timeToX(end), minimum + 4.0f); result.items.push_back({track.id, state.id, state.type, true, isSelected(state.id), minimum, maximum, top + 3.0f, top + rowHeight_ - 3.0f}); + decorate(result.items.back(), state.type, state.payload, registry_); } } return result; @@ -243,6 +388,75 @@ void ActionTimelineWidget::draw(IEditorOverlay& overlay) const { item.selected ? 0xf2b84bffU : (animationSection ? 0xa66bd4ffU : (item.state ? 0x568bd7ffU : 0x61c28bffU)); overlay.rectangle({item.minimumX, item.minimumY, 0.0f}, {item.maximumX, item.maximumY, 0.0f}, {color, 1.0f, true}); + if (item.maximumX - item.minimumX >= 48.0f) + overlay.text({item.minimumX + 5.0f, item.minimumY + 3.0f, 0.0f}, item.displayName, + {0xffffffffU, 1.0f, false}); + if (item.state && item.type.format() == "presentation:audio-state") { + const auto view = findItem(timeline, item.itemId); + auto* waveforms = eve::cap::query(); + if (view && waveforms) { + auto binding = action::ActionAudioBinding::fromPayload( + view->payload, action::ActionAudioShape::State); + const auto pixels = static_cast( + std::max(1.0f, std::floor(item.maximumX - item.minimumX))); + if (binding) { + action::ActionAudioWaveformRequest request{ + std::move(binding).takeValue(), difference(view->end, view->start), + std::min(pixels, kMaximumWaveformBuckets)}; + auto waveform = waveforms->waveform(request); + if (waveform && !waveform.value().buckets.empty()) { + const float middle = (item.minimumY + item.maximumY) * 0.5f; + const float amplitude = std::max(1.0f, (item.maximumY - item.minimumY) * 0.42f); + const float step = (item.maximumX - item.minimumX) / + static_cast(waveform.value().buckets.size()); + for (std::size_t index = 0; index < waveform.value().buckets.size(); ++index) { + const auto& bucket = waveform.value().buckets[index]; + const float x = item.minimumX + (static_cast(index) + 0.5f) * step; + overlay.line({x, middle - std::clamp(bucket.maximum, -1.0f, 1.0f) * amplitude, 0.0f}, + {x, middle - std::clamp(bucket.minimum, -1.0f, 1.0f) * amplitude, 0.0f}, + {0xe7f4ffffU, 1.0f, false}); + } + } + } + } + } + if (item.state && item.type.format() == "presentation:parameter-curve") { + const auto view = findItem(timeline, item.itemId); + auto binding = view ? action::ActionParameterCurveBinding::fromPayload(view->payload) + : Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "curve item is unavailable")); + if (binding) { + double minimum = binding.value().keys.front().value; + double maximum = minimum; + for (const auto& key : binding.value().keys) { + minimum = std::min(minimum, key.value); + maximum = std::max(maximum, key.value); + } + if (maximum - minimum < 1e-9) { + minimum -= 0.5; + maximum += 0.5; + } + const auto point = [&](double time, double value) { + const float x = item.minimumX + static_cast(time) * (item.maximumX - item.minimumX); + const float normalized = static_cast((value - minimum) / (maximum - minimum)); + const float y = item.maximumY - 2.0f - normalized * (item.maximumY - item.minimumY - 4.0f); + return OverlayPoint{x, y, 0.0f}; + }; + const auto segments = std::min( + kMaximumCurveSegments, + std::max(2, static_cast(item.maximumX - item.minimumX))); + auto previous = point(0.0, binding.value().sample(0.0)); + for (std::size_t segment = 1; segment <= segments; ++segment) { + const double time = static_cast(segment) / static_cast(segments); + const auto current = point(time, binding.value().sample(time)); + overlay.line(previous, current, {0xffdc7affU, 1.5f, false}); + previous = current; + } + for (const auto& key : binding.value().keys) + overlay.circle(point(key.time, key.value), 2.5f, + {item.selected ? 0xffffffffU : 0xffdc7affU, 1.0f, true}); + } + } if (item.state) { overlay.line({item.minimumX, item.minimumY, 0.0f}, {item.minimumX, item.maximumY, 0.0f}, {0xffffffffU, 2.0f, false}); @@ -257,14 +471,24 @@ void ActionTimelineWidget::draw(IEditorOverlay& overlay) const { std::optional ActionTimelineWidget::hitTest(float x, float y) const { const auto projected = layout(); + const auto containsY = [&](const TimelineItemGeometry& item) { + return y >= item.minimumY && y <= item.maximumY; + }; + // Resize handles remain the most precise affordance even when items overlap. for (auto it = projected.items.rbegin(); it != projected.items.rend(); ++it) { - if (y < it->minimumY || y > it->maximumY || x < it->minimumX - kHandleRadius || - x > it->maximumX + kHandleRadius) - continue; + if (!containsY(*it) || !it->state) continue; if (it->state && std::abs(x - it->minimumX) <= kHandleRadius) return TimelineHit{it->itemId, TimelineHitPart::StartHandle}; if (it->state && std::abs(x - it->maximumX) <= kHandleRadius) return TimelineHit{it->itemId, TimelineHitPart::EndHandle}; + } + // A point notify drawn over a state span must remain directly selectable. + for (auto it = projected.items.rbegin(); it != projected.items.rend(); ++it) { + if (!containsY(*it) || it->state) continue; + if (x >= it->minimumX && x <= it->maximumX) return TimelineHit{it->itemId, TimelineHitPart::Body}; + } + for (auto it = projected.items.rbegin(); it != projected.items.rend(); ++it) { + if (!containsY(*it) || !it->state) continue; if (x >= it->minimumX && x <= it->maximumX) return TimelineHit{it->itemId, TimelineHitPart::Body}; } return std::nullopt; @@ -282,7 +506,9 @@ EditorResult ActionTimelineWidget::pointerDown(float x, float y, bool addi return widgetError("editor.action.timeline.widget.item-missing", "Timeline item no longer exists", EditorStatus::Conflict); if (item->locked) return widgetError("editor.action.timeline.track-locked", "Action track is locked"); - auto selected = editor_.selectItem(item->itemId, additiveSelection); + const auto selectedIds = editor_.selectedItemIds(); + const bool alreadySelected = std::find(selectedIds.begin(), selectedIds.end(), item->itemId) != selectedIds.end(); + auto selected = editor_.selectItem(item->itemId, additiveSelection || alreadySelected); if (!selected.ok()) return selected; drag_ = DragState{item->itemId, hit->part, xToTime(x), item->start, item->end, item->start, item->end, item->state}; return eve::editing::applied(); @@ -296,30 +522,52 @@ EditorResult ActionTimelineWidget::updateDrag(float x) { if (!drag_->state) { auto moved = drag_->originalStart.tryAdd(delta); if (!moved) return widgetError("editor.action.timeline.widget.drag-overflow", "Notify drag overflowed"); - drag_->previewStart = std::clamp(moved.value(), Duration::zero(), duration); + const auto candidate = std::clamp(moved.value(), Duration::zero(), duration); + drag_->previewStart = magneticSnap(candidate, drag_->itemId).value_or(candidate); drag_->previewEnd = drag_->previewStart; return eve::editing::applied(); } if (drag_->part == TimelineHitPart::StartHandle) { auto moved = drag_->originalStart.tryAdd(delta); if (!moved) return widgetError("editor.action.timeline.widget.drag-overflow", "State start drag overflowed"); - drag_->previewStart = std::clamp(moved.value(), Duration::zero(), drag_->originalEnd); - drag_->previewEnd = drag_->originalEnd; + const auto candidate = std::clamp(moved.value(), Duration::zero(), drag_->originalEnd); + drag_->previewStart = magneticSnap(candidate, drag_->itemId).value_or(candidate); + drag_->previewStart = std::min(drag_->previewStart, drag_->originalEnd); + drag_->previewEnd = drag_->originalEnd; return eve::editing::applied(); } if (drag_->part == TimelineHitPart::EndHandle) { auto moved = drag_->originalEnd.tryAdd(delta); if (!moved) return widgetError("editor.action.timeline.widget.drag-overflow", "State end drag overflowed"); drag_->previewStart = drag_->originalStart; - drag_->previewEnd = std::clamp(moved.value(), drag_->originalStart, duration); + const auto candidate = std::clamp(moved.value(), drag_->originalStart, duration); + drag_->previewEnd = magneticSnap(candidate, drag_->itemId).value_or(candidate); + drag_->previewEnd = std::max(drag_->previewEnd, drag_->originalStart); return eve::editing::applied(); } const auto span = drag_->originalEnd.nanoseconds() - drag_->originalStart.nanoseconds(); auto moved = drag_->originalStart.tryAdd(delta); if (!moved) return widgetError("editor.action.timeline.widget.drag-overflow", "State drag overflowed"); const auto latestStart = Duration::fromNanoseconds(duration.nanoseconds() - span); - drag_->previewStart = std::clamp(moved.value(), Duration::zero(), latestStart); - drag_->previewEnd = Duration::fromNanoseconds(drag_->previewStart.nanoseconds() + span); + drag_->previewStart = std::clamp(moved.value(), Duration::zero(), latestStart); + drag_->previewEnd = Duration::fromNanoseconds(drag_->previewStart.nanoseconds() + span); + const auto snappedStart = magneticSnap(drag_->previewStart, drag_->itemId); + const auto snappedEnd = magneticSnap(drag_->previewEnd, drag_->itemId); + Duration adjustment = Duration::zero(); + if (snappedStart && snappedEnd) { + const auto startDelta = difference(*snappedStart, drag_->previewStart); + const auto endDelta = difference(*snappedEnd, drag_->previewEnd); + adjustment = std::abs(startDelta.nanoseconds()) <= std::abs(endDelta.nanoseconds()) ? startDelta : endDelta; + } else if (snappedStart) { + adjustment = difference(*snappedStart, drag_->previewStart); + } else if (snappedEnd) { + adjustment = difference(*snappedEnd, drag_->previewEnd); + } + const auto adjustedStart = drag_->previewStart.tryAdd(adjustment); + if (adjustedStart && adjustedStart.value() >= Duration::zero() && adjustedStart.value() <= latestStart) { + drag_->previewStart = adjustedStart.value(); + drag_->previewEnd = Duration::fromNanoseconds(drag_->previewStart.nanoseconds() + span); + } return eve::editing::applied(); } @@ -331,6 +579,8 @@ EditorResult ActionTimelineWidget::pointerUp(float x) { const DragState completed = *drag_; drag_.reset(); const auto item = findItem(editor_.target().timeline(), completed.itemId); + if (completed.part == TimelineHitPart::Body && editor_.selectionCount() > 1) + return editor_.moveSelection(difference(completed.previewStart, completed.originalStart)); if (item && item->animationSection) { if (completed.part == TimelineHitPart::Body) return editor_.moveAnimationSection(completed.itemId, @@ -353,7 +603,25 @@ EditorResult ActionTimelineWidget::seek(float x) { return editor_.seek(xTo EditorResult ActionTimelineWidget::inspectSelection(IEditorInspector& inspector) { const auto selected = editor_.selectedItemIds(); - if (selected.size() != 1) return eve::editing::noOp(); + if (selected.size() > 1) { + auto range = editor_.selectionRange(); + if (!range.ok()) return EditorResult::failure(range.status()); + float startSeconds = static_cast(range.value().start.seconds()); + float endSeconds = static_cast(range.value().end.seconds()); + const float maximum = static_cast(editor_.target().timeline().duration.seconds()); + inspector.beginGroup("action.timeline.selection", "Selection"); + bool changed = inspector.scalar("start", "Start", startSeconds, 0.0f, maximum); + changed = inspector.scalar("end", "End", endSeconds, 0.0f, maximum) || changed; + inspector.endGroup(); + if (!changed) return eve::editing::noOp(); + auto start = Duration::fromSeconds(startSeconds); + auto end = Duration::fromSeconds(endSeconds); + if (!start || !end) + return widgetError("editor.action.timeline.widget.selection-time-invalid", + "Selection bounds are invalid"); + return editor_.scaleSelection(start.value(), end.value()); + } + if (selected.empty()) return eve::editing::noOp(); auto item = findItem(editor_.target().timeline(), selected.front()); if (!item) return widgetError("editor.action.timeline.widget.item-missing", "Selected item no longer exists", @@ -407,6 +675,63 @@ EditorResult ActionTimelineWidget::inspectSelection(IEditorInspector& insp return editor_.editAnimationSectionFull(std::move(edited)); } + if (item->state && item->type.format() == "presentation:parameter-curve") { + auto binding = action::ActionParameterCurveBinding::fromPayload(item->payload); + if (!binding) + return widgetError("editor.action.timeline.widget.parameter-curve-invalid", + "Selected parameter curve payload is invalid"); + std::string target = binding.value().target.format(); + std::string operation = std::string(action::actionParameterOperationName(binding.value().operation)); + Value::Array keys; + bool changed = false; + inspector.beginGroup("action.timeline.parameter-curve", "Parameter Curve"); + changed = inspector.string("target", "Target", target) || changed; + changed = inspector.string("operation", "Operation", operation) || changed; + for (std::size_t index = 0; index < binding.value().keys.size(); ++index) { + auto key = binding.value().keys[index]; + float time = static_cast(key.time); + float value = static_cast(key.value); + float inTangent = static_cast(key.inTangent); + float outTangent = static_cast(key.outTangent); + std::string interpolation = std::string(action::actionParameterInterpolationName(key.interpolation)); + inspector.beginGroup("action.timeline.parameter-key." + std::to_string(index), + "Key " + std::to_string(index)); + const bool endpoint = index == 0 || index + 1 == binding.value().keys.size(); + const float minimumTime = endpoint ? static_cast(key.time) + : static_cast(binding.value().keys[index - 1].time); + const float maximumTime = endpoint ? static_cast(key.time) + : static_cast(binding.value().keys[index + 1].time); + changed = inspector.scalar("time", "Time", time, minimumTime, maximumTime) || changed; + changed = inspector.scalar("value", "Value", value, std::numeric_limits::lowest(), + std::numeric_limits::max()) || changed; + changed = inspector.scalar("inTangent", "In Tangent", inTangent, + std::numeric_limits::lowest(), + std::numeric_limits::max()) || changed; + changed = inspector.scalar("outTangent", "Out Tangent", outTangent, + std::numeric_limits::lowest(), + std::numeric_limits::max()) || changed; + changed = inspector.string("interpolation", "Interpolation", interpolation) || changed; + inspector.endGroup(); + keys.emplace_back(Value::Object{{"time", static_cast(time)}, + {"value", static_cast(value)}, + {"inTangent", static_cast(inTangent)}, + {"outTangent", static_cast(outTangent)}, + {"interpolation", std::move(interpolation)}}); + } + inspector.endGroup(); + if (!changed) return eve::editing::applied(); + auto payload = item->payload; + payload["target"] = std::move(target); + payload["operation"] = std::move(operation); + payload["keys"] = Value(std::move(keys)); + auto validated = action::ActionParameterCurveBinding::fromPayload(payload); + if (!validated) + return widgetError("editor.action.timeline.widget.parameter-curve-contract", + "Parameter curve fields violate the block contract"); + return editor_.updateItem(item->itemId, item->type, + validated.value().toPayload(std::move(payload))); + } + float startSeconds = static_cast(item->start.seconds()); float endSeconds = static_cast(item->end.seconds()); std::string type = item->type.format(); @@ -457,9 +782,12 @@ EditorResult ActionTimelineWidget::inspectSelection(IEditorInspector& insp std::vector ActionTimelineWidget::commands() const { const bool selected = editor_.selectionCount() > 0; + const bool multi = editor_.selectionCount() > 1; return {{TimelineWidgetCommand::Copy, "Copy", "Ctrl+C", selected}, {TimelineWidgetCommand::Paste, "Paste at Playhead", "Ctrl+V", clipboardAnchor_.has_value()}, {TimelineWidgetCommand::DeleteSelection, "Delete", "Delete", selected}, + {TimelineWidgetCommand::AlignSelectionStart, "Align Starts", "Shift+[", multi}, + {TimelineWidgetCommand::AlignSelectionEnd, "Align Ends", "Shift+]", multi}, {TimelineWidgetCommand::Undo, "Undo", "Ctrl+Z", editor_.canUndo()}, {TimelineWidgetCommand::Redo, "Redo", "Ctrl+Y", editor_.canRedo()}, {TimelineWidgetCommand::PlayPause, editor_.playing() ? "Pause" : "Play", "Space", true}}; @@ -487,6 +815,8 @@ EditorResult ActionTimelineWidget::invoke(TimelineWidgetCommand command) { "editor.action.timeline.widget.paste", "Could not paste timeline selection"); } case TimelineWidgetCommand::DeleteSelection: return editor_.deleteSelection(); + case TimelineWidgetCommand::AlignSelectionStart: return editor_.alignSelectionStart(); + case TimelineWidgetCommand::AlignSelectionEnd: return editor_.alignSelectionEnd(); case TimelineWidgetCommand::Undo: { auto result = editor_.undo(); if (result.ok()) return eve::editing::applied(); @@ -512,6 +842,8 @@ EditorResult ActionTimelineWidget::handleShortcut(std::string_view shortcu if (shortcut == "Ctrl+C") return invoke(TimelineWidgetCommand::Copy); if (shortcut == "Ctrl+V") return invoke(TimelineWidgetCommand::Paste); if (shortcut == "Delete" || shortcut == "Backspace") return invoke(TimelineWidgetCommand::DeleteSelection); + if (shortcut == "Shift+[") return invoke(TimelineWidgetCommand::AlignSelectionStart); + if (shortcut == "Shift+]") return invoke(TimelineWidgetCommand::AlignSelectionEnd); if (shortcut == "Ctrl+Z") return invoke(TimelineWidgetCommand::Undo); if (shortcut == "Ctrl+Y" || shortcut == "Ctrl+Shift+Z") return invoke(TimelineWidgetCommand::Redo); if (shortcut == "Space") return invoke(TimelineWidgetCommand::PlayPause); diff --git a/src/modules/action/editor/ActionTimelineWidget.h b/src/modules/action/editor/ActionTimelineWidget.h index 298fd9d0..0d7fa2e7 100644 --- a/src/modules/action/editor/ActionTimelineWidget.h +++ b/src/modules/action/editor/ActionTimelineWidget.h @@ -19,6 +19,31 @@ enum class TimelineHitPart : std::uint8_t { Body, StartHandle, EndHandle }; /** @brief Observable pointer-drag lifecycle state. */ enum class TimelineDragStatus : std::uint8_t { Idle, Active }; +/** @brief Stable semantic thumbnail kind projected for one timeline block. */ +enum class TimelineItemVisual : std::uint8_t { + Animation, + Gameplay, + Damage, + Vfx, + Audio, + Prefab, + Camera, + Hitbox, + Defense, + Input, + Collision, + Movement, + Curve, + Custom +}; + +/** + * @brief Return the stable script-facing name for a timeline visual kind. + * @param visual Semantic projection kind. + * @return Static text valid for the lifetime of the process. + */ +[[nodiscard]] std::string_view timelineItemVisualName(TimelineItemVisual visual) noexcept; + /** @brief Owning geometry projected for one timeline item. */ struct TimelineItemGeometry { LogicalId trackId; @@ -30,6 +55,12 @@ struct TimelineItemGeometry { float maximumX = 0.0f; float minimumY = 0.0f; float maximumY = 0.0f; + /** @brief Registry-owned display name copied into this frame projection. */ + std::string displayName; + /** @brief Compact owning payload summary suitable for a tooltip or block subtitle. */ + std::string detail; + /** @brief Semantic mini-preview selected from the registered type, never persisted. */ + TimelineItemVisual visual = TimelineItemVisual::Custom; }; /** @brief One deterministic ruler tick projected into host coordinates. */ @@ -50,12 +81,23 @@ struct TimelineWidgetLayout { float width = 0.0f; float height = 0.0f; float playheadX = 0.0f; + /** @brief Whether an audio module currently provides waveform projection. */ + bool audioWaveformsAvailable = false; std::vector items; std::vector rulerTicks; }; /** @brief Standard context-menu and keyboard actions exposed by the widget. */ -enum class TimelineWidgetCommand : std::uint8_t { Copy, Paste, DeleteSelection, Undo, Redo, PlayPause }; +enum class TimelineWidgetCommand : std::uint8_t { + Copy, + Paste, + DeleteSelection, + Undo, + Redo, + PlayPause, + AlignSelectionStart, + AlignSelectionEnd +}; /** @brief One host-renderable command entry. */ struct TimelineWidgetCommandDescriptor { @@ -109,6 +151,8 @@ public: /** @brief Seek the editor preview cursor from a host-space coordinate. */ [[nodiscard]] EditorResult seek(float x); + /** @brief Convert a host-space coordinate to its snapped authoritative timeline time. */ + [[nodiscard]] Duration timeAt(float x) const noexcept { return xToTime(x); } /** @brief Present and apply selected item timing, type and JSON payload fields. */ [[nodiscard]] EditorResult inspectSelection(IEditorInspector& inspector); @@ -140,10 +184,12 @@ private: bool state = false; }; - [[nodiscard]] float timeToX(Duration time) const noexcept; - [[nodiscard]] Duration xToTime(float x) const noexcept; - [[nodiscard]] EditorResult updateDrag(float x); - [[nodiscard]] LogicalId generatedItemId(); + [[nodiscard]] float timeToX(Duration time) const noexcept; + [[nodiscard]] Duration xToTime(float x) const noexcept; + [[nodiscard]] std::optional magneticSnap(Duration candidate, + const LogicalId& excludedItem) const noexcept; + [[nodiscard]] EditorResult updateDrag(float x); + [[nodiscard]] LogicalId generatedItemId(); ActionTimelineEditor& editor_; const action::ActionNotifyRegistry& registry_; diff --git a/src/modules/action/game_event/ActionGameEventSink.cpp b/src/modules/action/game_event/ActionGameEventSink.cpp new file mode 100644 index 00000000..646f400f --- /dev/null +++ b/src/modules/action/game_event/ActionGameEventSink.cpp @@ -0,0 +1,82 @@ +#include "action/game_event/ActionGameEventSink.h" + +#include "common/Capability.h" + +#include + +namespace eve::action::game_event_adapter { +namespace { + +Result failure(DiagnosticCode code, std::string message, std::string path) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path))); +} + +} // namespace + +ActionGameEventSink::ActionGameEventSink(eve::game_event::GameEventLog& log, ActionSubjectResolver resolver) + : log_(&log), resolver_(std::move(resolver)) {} + +ActionGameEventSink::~ActionGameEventSink() { cap::removeListener(this); } + +bool ActionGameEventSink::enabled() const { + for (std::size_t index = 0; index < cap::listenerCount(); ++index) + if (cap::listenerAt(index) == this) return true; + return false; +} + +void ActionGameEventSink::setEnabled(bool value) { + const bool current = enabled(); + if (value && !current) + cap::addListener(this); + else if (!value && current) + cap::removeListener(this); +} + +Result ActionGameEventSink::emit(const ActionGameplayEventBinding& binding, + const ActionNotifyContext& context) { + if (!log_) return failure(DiagnosticCode::NotFound, "game event log is unavailable", "log"); + if (!resolver_) return failure(DiagnosticCode::NotFound, "action subject resolver is unavailable", "resolver"); + + std::optional source; + if (context.source) { + auto resolved = resolver_(*context.source); + if (!resolved) return Result::failure(resolved.status()); + source = resolved.value(); + } + SubjectRef subject = SubjectRef::nil(); + if (binding.targetIndex) { + if (*binding.targetIndex >= context.targets.size()) + return failure(DiagnosticCode::NotFound, "gameplay event target index is unavailable", "targetIndex"); + auto resolved = resolver_(context.targets[*binding.targetIndex]); + if (!resolved) return Result::failure(resolved.status()); + subject = resolved.value(); + } else if (source) { + subject = *source; + } else { + return failure(DiagnosticCode::NotFound, "gameplay event has no source or target subject", "subject"); + } + + auto payload = binding.data.toJson(); + if (!payload) return Result::failure(payload.status()); + auto schema = LogicalId::parse("action:gameplay-event-v1"); + if (!schema) return failure(DiagnosticCode::Failed, "built-in gameplay event schema is invalid", "schemaId"); + + eve::game_event::GameEvent envelope; + envelope.type = binding.tag; + envelope.source = source ? source->format() : std::string{}; + envelope.subject = subject.format(); + envelope.schemaId = *schema; + envelope.schemaVersion = SchemaVersion(1); + envelope.tick = context.tick; + envelope.payload = std::move(payload).takeValue(); + auto appended = log_->append(std::move(envelope)); + if (!appended) return Result::failure(appended.status()); + lastSequence_ = appended.value(); + return Result::success(Status::success(StatusCode::Applied)); +} + +std::optional ActionGameEventSink::lastSequence() const noexcept { + return lastSequence_; +} + +} // namespace eve::action::game_event_adapter diff --git a/src/modules/action/game_event/ActionGameEventSink.h b/src/modules/action/game_event/ActionGameEventSink.h new file mode 100644 index 00000000..636eb9ac --- /dev/null +++ b/src/modules/action/game_event/ActionGameEventSink.h @@ -0,0 +1,54 @@ +#pragma once + +/** @file ActionGameEventSink.h @brief Persistent GameEventLog adapter for action events. */ + +#include "action/ActionGameplayEventBlock.h" +#include "common/SubjectRef.h" +#include "game_event/GameEvent.h" + +#include +#include + +namespace eve::action::game_event_adapter { + +/** + * @brief Resolves a stable gameplay subject for a generation-qualified ECS handle. + * @remarks Called synchronously on the owner thread without locks. It must not + * throw, reenter the adapter, or retain the handle. The result is owning. + */ +using ActionSubjectResolver = std::function(ecs::EntityHandle)>; + +/** + * @brief Explicit bridge from gameplay:event blocks to one persistent GameEventLog. + * + * The caller owns the log and subject resolver. This adapter owns no event-domain + * state; the log remains the only ordering, identity, persistence, and restore authority. + */ +class ActionGameEventSink final : public IActionGameplayEventSink { +public: + /** + * @brief Construct from borrowed event authority and an owning resolver callback. + * @param log Log that must outlive this adapter. + * @param resolver Synchronous stable-subject resolver copied into the adapter. + * @lifetime The adapter retains a raw pointer to log until destruction. + */ + ActionGameEventSink(eve::game_event::GameEventLog& log, ActionSubjectResolver resolver); + ~ActionGameEventSink() override; + + /** @brief Opt this adapter into or out of gameplay-event dispatch. */ + void setEnabled(bool enabled); + /** @brief Return whether this exact adapter is registered. */ + [[nodiscard]] bool enabled() const; + /** @copydoc IActionGameplayEventSink::emit */ + [[nodiscard]] Result emit(const ActionGameplayEventBinding& binding, + const ActionNotifyContext& context) override; + /** @brief Return the last successfully appended stream-local sequence. */ + [[nodiscard]] std::optional lastSequence() const noexcept; + +private: + eve::game_event::GameEventLog* log_ = nullptr; + ActionSubjectResolver resolver_; + std::optional lastSequence_; +}; + +} // namespace eve::action::game_event_adapter diff --git a/src/modules/action/input/ActionComboWindowState.cpp b/src/modules/action/input/ActionComboWindowState.cpp new file mode 100644 index 00000000..c89106cd --- /dev/null +++ b/src/modules/action/input/ActionComboWindowState.cpp @@ -0,0 +1,106 @@ +#include "action/input/ActionComboWindowState.h" + +#include "common/Capability.h" + +#include +#include + +namespace eve::action::input { +namespace { + +template +Result failure(DiagnosticCode code, std::string message, std::string path) { + return Result::failure(Diagnostic::error(code, std::move(message), std::move(path))); +} + +} // namespace + +ActionComboWindowState::ActionComboWindowState(ComboSubjectResolver resolver) : resolver_(std::move(resolver)) {} + +ActionComboWindowState::~ActionComboWindowState() { cap::removeListener(this); } + +bool ActionComboWindowState::enabled() const { + for (std::size_t index = 0; index < cap::listenerCount(); ++index) + if (cap::listenerAt(index) == this) return true; + return false; +} + +void ActionComboWindowState::setEnabled(bool value) { + const bool current = enabled(); + if (value && !current) + cap::addListener(this); + else if (!value && current) + cap::removeListener(this); +} + +bool ActionComboWindowState::supports(ActionStateWindowKind kind) const noexcept { + return kind == ActionStateWindowKind::Combo; +} + +Result ActionComboWindowState::enter(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) { + if (!supports(binding.kind)) + return failure(DiagnosticCode::Unsupported, "input adapter does not own this window kind", "kind"); + if (!resolver_) return failure(DiagnosticCode::NotFound, "combo subject resolver is unavailable", "resolver"); + std::optional handle; + if (binding.targetIndex) { + if (*binding.targetIndex >= context.targets.size()) + return failure(DiagnosticCode::NotFound, "combo target index is unavailable", "targetIndex"); + handle = context.targets[*binding.targetIndex]; + } else { + handle = context.source; + } + if (!handle) return failure(DiagnosticCode::NotFound, "combo window has no source or target", "subject"); + auto subject = resolver_(*handle); + if (!subject) return Result::failure(subject.status()); + if (!subject.value().isValid()) + return failure(DiagnosticCode::InvalidArgument, "combo-window subject is nil", "subject"); + + ActiveKey key{context.executionId, event.itemId.format()}; + const auto found = active_.find(key); + if (found != active_.end()) { + if (found->second.subject == subject.value() && found->second.input == binding.resource) + return Result::success(Status::success(StatusCode::NoOp)); + return failure(DiagnosticCode::Conflict, "combo-window key has different active data", "itemId"); + } + active_.emplace(std::move(key), ActiveCombo{event.itemId, subject.value(), binding.resource}); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result ActionComboWindowState::exit(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) { + if (!supports(binding.kind)) + return failure(DiagnosticCode::Unsupported, "input adapter does not own this window kind", "kind"); + const ActiveKey key{context.executionId, event.itemId.format()}; + const auto found = active_.find(key); + if (found == active_.end()) return Result::success(Status::success(StatusCode::NoOp)); + active_.erase(found); + return Result::success(Status::success(StatusCode::Applied)); +} + +std::vector ActionComboWindowState::availableInputs(SubjectRef subject) const { + std::vector result; + for (const auto& [key, combo] : active_) { + (void)key; + if (combo.subject == subject) result.push_back(combo.input); + } + std::sort(result.begin(), result.end()); + result.erase(std::unique(result.begin(), result.end()), result.end()); + return result; +} + +Result ActionComboWindowState::match(SubjectRef subject, std::string_view input) const { + if (!subject.isValid()) + return failure(DiagnosticCode::InvalidArgument, "combo subject is nil", "subject"); + if (input.empty()) return failure(DiagnosticCode::InvalidArgument, "combo input is empty", "input"); + for (const auto& [key, combo] : active_) { + if (combo.subject == subject && combo.input == input) + return Result::success( + ActionComboMatch{key.first, combo.itemId, combo.subject, combo.input}); + } + return failure(DiagnosticCode::NotFound, "no active combo window accepts the input", "input"); +} + +} // namespace eve::action::input diff --git a/src/modules/action/input/ActionComboWindowState.h b/src/modules/action/input/ActionComboWindowState.h new file mode 100644 index 00000000..52120e9e --- /dev/null +++ b/src/modules/action/input/ActionComboWindowState.h @@ -0,0 +1,77 @@ +#pragma once + +/** @file ActionComboWindowState.h @brief Semantic input matching for active Montage combo windows. */ + +#include "action/ActionStateWindowBlock.h" +#include "common/SubjectRef.h" + +#include +#include +#include +#include + +namespace eve::action::input { + +/** + * @brief Resolves an owning stable subject from a generation-qualified ECS handle. + * @remarks Called synchronously on the owner thread without locks. It must not + * throw, reenter this state owner, or retain the handle. + */ +using ComboSubjectResolver = std::function(ecs::EntityHandle)>; + +/** @brief Owning deterministic match between one semantic input and an active combo window. */ +struct ActionComboMatch { + ActionExecutionId executionId; + LogicalId itemId; + SubjectRef subject; + std::string input; +}; + +/** + * @brief Authoritative projection of active semantic combo-input windows. + * + * This object does not read devices or submit gameplay commands. Player, AI, and + * script input adapters map their source into the same semantic input string and + * query this owner before choosing the next action. All methods are owner-thread-only. + */ +class ActionComboWindowState final : public IActionStateWindowSink { +public: + /** @brief Construct with a synchronous stable-subject resolver. */ + explicit ActionComboWindowState(ComboSubjectResolver resolver); + ~ActionComboWindowState() override; + + /** @brief Opt this state owner into or out of Action window dispatch. */ + void setEnabled(bool enabled); + /** @brief Return whether this exact owner is registered. */ + [[nodiscard]] bool enabled() const; + /** @copydoc IActionStateWindowSink::supports */ + [[nodiscard]] bool supports(ActionStateWindowKind kind) const noexcept override; + /** @copydoc IActionStateWindowSink::enter */ + [[nodiscard]] Result enter(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) override; + /** @copydoc IActionStateWindowSink::exit */ + [[nodiscard]] Result exit(const ActionStateWindowBinding& binding, + const ActionTimelineEvent& event, + const ActionNotifyContext& context) override; + + /** @brief Return sorted unique semantic inputs currently accepted for a subject. */ + [[nodiscard]] std::vector availableInputs(SubjectRef subject) const; + /** @brief Resolve the first deterministic active window accepting an input. */ + [[nodiscard]] Result match(SubjectRef subject, std::string_view input) const; + /** @brief Return the number of active execution/item keys. */ + [[nodiscard]] std::size_t activeCount() const noexcept { return active_.size(); } + +private: + using ActiveKey = std::pair; + struct ActiveCombo { + LogicalId itemId; + SubjectRef subject; + std::string input; + }; + + ComboSubjectResolver resolver_; + std::map active_; +}; + +} // namespace eve::action::input diff --git a/src/modules/animation/AnimClip.cpp b/src/modules/animation/AnimClip.cpp index 1932b676..f8835484 100644 --- a/src/modules/animation/AnimClip.cpp +++ b/src/modules/animation/AnimClip.cpp @@ -478,6 +478,17 @@ float AnimClip::wrapTime(float time) const { return clampf(time, 0.f, duration_); } +std::unique_ptr AnimClip::clone() const { + auto result = std::make_unique(name_); + result->duration_ = duration_; + result->loop_ = loop_; + result->sampleRate_ = sampleRate_; + result->tracks_ = tracks_; + result->events_ = events_; + result->syncMarkers_ = syncMarkers_; + return result; +} + void AnimClip::sampleVec3(const std::vector& keys, float time, float& x, float& y, float& z, bool& ok) { ok = false; if (keys.empty()) return; diff --git a/src/modules/animation/AnimClip.h b/src/modules/animation/AnimClip.h index c3e9f96a..ee822439 100644 --- a/src/modules/animation/AnimClip.h +++ b/src/modules/animation/AnimClip.h @@ -4,6 +4,7 @@ #include "animation/AnimSmr.h" #include "common/Result.h" +#include #include #include #include @@ -287,6 +288,9 @@ public: /** @brief Wrap or clamp time according to loop flag. */ float wrapTime(float time) const; + /** @brief Return a fully owning deep copy suitable for runtime hot replacement. */ + [[nodiscard]] std::unique_ptr clone() const; + /** * @brief Replace this clip's content with `other`'s (payload moved, not * copied). Used by hot reload so registered clip instances keep diff --git a/src/modules/animation/AnimMath.h b/src/modules/animation/AnimMath.h index 6cd2a37d..daca3f4f 100644 --- a/src/modules/animation/AnimMath.h +++ b/src/modules/animation/AnimMath.h @@ -98,6 +98,10 @@ inline void multiplyQuat(float ax, float ay, float az, float aw, float bx, float * skeleton bind local for BindPose-mode absolute samples. Translation adds * (sample - reference); scale multiplies by sample/reference (or sample when * reference is identity); rotation right-multiplies the slerped identity-to-delta quaternion. + * @param base Accumulated transform mutated in place. + * @param sample Evaluated additive-layer transform. + * @param reference Reference transform used as the additive identity. + * @param weight Finite blend influence, clamped to [0,1]. */ inline void applyAdditiveTRS(TransformTRS &base, const TransformTRS &sample, const TransformTRS &reference, float weight) { diff --git a/src/modules/animation/AnimSkin.cpp b/src/modules/animation/AnimSkin.cpp index 75174356..820329fb 100644 --- a/src/modules/animation/AnimSkin.cpp +++ b/src/modules/animation/AnimSkin.cpp @@ -181,6 +181,55 @@ AnimSkin* AnimSkin::fromModel(const model3d::ModelData* model, int meshIndex, co return skin; } +Result> AnimSkin::fromStreams(AnimSkinStreamData streams) { + auto fail = [](const char* message) { + return Result>::failure(Diagnostic::error( + DiagnosticCode::InvalidArgument, message, "animation.skin.streams")); + }; + const std::size_t vertices = streams.vertexCount > 0 ? static_cast(streams.vertexCount) : 0U; + const std::size_t joints = streams.skeletonBones.size(); + if (vertices == 0 || vertices > 8U * 1024U * 1024U || + streams.bindPositions.size() != vertices * 3U || + (!streams.bindNormals.empty() && streams.bindNormals.size() != vertices * 3U) || + joints == 0 || joints > 65535U || streams.boneNames.size() != joints || + streams.inverseBindMatrices.size() != joints || + streams.vertexSkinJoints.size() != vertices * kMaxInfluences || + streams.vertexWeights.size() != vertices * kMaxInfluences) + return fail("AnimSkin native stream sizes are invalid"); + for (float value : streams.bindPositions) if (!std::isfinite(value)) return fail("AnimSkin position is non-finite"); + for (float value : streams.bindNormals) if (!std::isfinite(value)) return fail("AnimSkin normal is non-finite"); + for (std::size_t joint = 0; joint < joints; ++joint) { + if (streams.skeletonBones[joint] < 0 || streams.boneNames[joint].empty()) + return fail("AnimSkin joint identity is invalid"); + for (float value : streams.inverseBindMatrices[joint]) + if (!std::isfinite(value)) return fail("AnimSkin inverse bind is non-finite"); + } + for (std::size_t index = 0; index < streams.vertexWeights.size(); ++index) { + const int joint = streams.vertexSkinJoints[index]; + const float weight = streams.vertexWeights[index]; + if (!std::isfinite(weight) || weight < 0.F || + (weight > 0.F && (joint < 0 || static_cast(joint) >= joints)) || + (weight == 0.F && joint >= static_cast(joints))) + return fail("AnimSkin influence is invalid"); + } + auto skin = std::make_unique(); + skin->vertexCount_ = streams.vertexCount; + skin->bindPos_ = std::move(streams.bindPositions); + skin->bindNrm_ = std::move(streams.bindNormals); + skin->skeletonBone_ = std::move(streams.skeletonBones); + skin->skinBoneNames_ = std::move(streams.boneNames); + skin->inverseBind_.resize(joints); + for (std::size_t joint = 0; joint < joints; ++joint) + std::copy(streams.inverseBindMatrices[joint].begin(), streams.inverseBindMatrices[joint].end(), + skin->inverseBind_[joint].m); + skin->influences_.resize(vertices * kMaxInfluences); + for (std::size_t index = 0; index < skin->influences_.size(); ++index) { + skin->influences_[index].bone = streams.vertexWeights[index] > 0.F ? streams.vertexSkinJoints[index] : -1; + skin->influences_[index].weight = streams.vertexWeights[index]; + } + return Result>::success(std::move(skin)); +} + int AnimSkin::getSkeletonBone(int skinBoneIndex) const { requireSkinBone(skinBoneIndex); return skeletonBone_[static_cast(skinBoneIndex)]; @@ -268,6 +317,13 @@ int AnimSkin::getVertexBone(int vertexIndex, int influenceIndex) const { return skeletonBone_[static_cast(inf.bone)]; } +int AnimSkin::getVertexSkinJoint(int vertexIndex, int influenceIndex) const { + requireVertex(vertexIndex); + if (influenceIndex < 0 || influenceIndex >= kMaxInfluences) + throw Exception("AnimSkin.getVertexSkinJoint: influenceIndex must be 0..%d", kMaxInfluences - 1); + return influences_[static_cast(vertexIndex) * kMaxInfluences + influenceIndex].bone; +} + float AnimSkin::getVertexWeight(int vertexIndex, int influenceIndex) const { requireVertex(vertexIndex); if (influenceIndex < 0 || influenceIndex >= kMaxInfluences) { diff --git a/src/modules/animation/AnimSkin.h b/src/modules/animation/AnimSkin.h index c3e2927b..28274964 100644 --- a/src/modules/animation/AnimSkin.h +++ b/src/modules/animation/AnimSkin.h @@ -1,7 +1,10 @@ #pragma once #include "animation/AnimMath.h" +#include "common/Result.h" +#include +#include #include #include @@ -19,6 +22,18 @@ namespace eve::animation { class AnimSkeleton; class AnimPose; +/** @brief Owned native streams for constructing a skin outside the model importer. */ +struct AnimSkinStreamData { + int vertexCount = 0; + std::vector bindPositions; + std::vector bindNormals; + std::vector skeletonBones; + std::vector boneNames; + std::vector> inverseBindMatrices; + std::vector vertexSkinJoints; + std::vector vertexWeights; +}; + /** * @brief CPU linear-blend skinning binding for one mesh against an AnimSkeleton. * @@ -44,6 +59,14 @@ public: */ static AnimSkin* fromModel(const model3d::ModelData* model, int meshIndex, const AnimSkeleton* skeleton); + /** + * @brief Construct a caller-owned skin from validated native streams. + * @param streams Moved bind geometry, joint identities, inverse binds and four influences per vertex. + * @return Owned skin or a structured diagnostic; no partial skin is published. + * @thread Synchronous CPU operation. The returned skin has caller-selected thread affinity. + */ + [[nodiscard]] static Result> fromStreams(AnimSkinStreamData streams); + int getVertexCount() const { return vertexCount_; } int getBoneCount() const { return static_cast(skeletonBone_.size()); } int getInfluenceCount() const { return kMaxInfluences; } @@ -132,6 +155,8 @@ public: /** @brief Influence slot i (0..3) for vertex: skeleton bone index or -1. */ int getVertexBone(int vertexIndex, int influenceIndex) const; + /** @brief Influence slot i (0..3) for vertex: skin-local joint index or -1. */ + int getVertexSkinJoint(int vertexIndex, int influenceIndex) const; float getVertexWeight(int vertexIndex, int influenceIndex) const; private: diff --git a/src/modules/animation/Animation.cpp b/src/modules/animation/Animation.cpp index 077a8ea7..fa102b93 100644 --- a/src/modules/animation/Animation.cpp +++ b/src/modules/animation/Animation.cpp @@ -784,6 +784,7 @@ void Animation::expose(ssq::Table &table) { skin.addFunc("getBindPositionY", &AnimSkin::getBindPositionY); skin.addFunc("getBindPositionZ", &AnimSkin::getBindPositionZ); skin.addFunc("getVertexBone", &AnimSkin::getVertexBone); + skin.addFunc("getVertexSkinJoint", &AnimSkin::getVertexSkinJoint); skin.addFunc("getVertexWeight", &AnimSkin::getVertexWeight); skin.addFunc("updateSkinnedPositions", &AnimSkin::updateSkinnedPositions); skin.addFunc("hasSkinnedPositions", &AnimSkin::hasSkinnedPositions); diff --git a/src/modules/animation/MontageCoordinator.cpp b/src/modules/animation/MontageCoordinator.cpp index 91fd4950..c0dee9c6 100644 --- a/src/modules/animation/MontageCoordinator.cpp +++ b/src/modules/animation/MontageCoordinator.cpp @@ -1,9 +1,11 @@ #include "animation/MontageCoordinator.h" +#include "animation/AnimLayerMixer.h" #include "animation/AnimPose.h" #include "animation/AnimSkeleton.h" #include +#include #include #include @@ -17,13 +19,20 @@ Result coordinatorError(DiagnosticCode code, std::string message, std::string } // namespace -MontageCoordinator::MontageCoordinator(AnimSkeleton& skeleton) : skeleton_(skeleton) {} +MontageCoordinator::MontageCoordinator(AnimSkeleton& skeleton) + : skeleton_(skeleton), composedPose_(std::make_unique(skeleton.getBoneCount())) { + skeleton_.applyBindPose(composedPose_.get()); +} void MontageCoordinator::ensureLayer(std::size_t layer) { while (layers_.size() <= layer) { Layer entry; - entry.pose = std::make_unique(skeleton_.getBoneCount()); + entry.pose = std::make_unique(skeleton_.getBoneCount()); + entry.rawPose = std::make_unique(skeleton_.getBoneCount()); + entry.blendScratch = std::make_unique(skeleton_.getBoneCount()); skeleton_.applyBindPose(entry.pose.get()); + skeleton_.applyBindPose(entry.rawPose.get()); + skeleton_.applyBindPose(entry.blendScratch.get()); layers_.push_back(std::move(entry)); } } @@ -53,13 +62,14 @@ Result MontageCoordinator::play(std::size_t layer, action::Action return coordinatorError(DiagnosticCode::Failed, "montage slot generation is exhausted", "slot"); auto candidate = std::make_unique(skeleton_); + if (entry.rootMotionReceiver) candidate->setRootMotionReceiver(*entry.rootMotionReceiver); const Duration blendOut = timeline.montage.defaultBlendIn; auto prepared = candidate->prepare(std::move(timeline), std::move(clips)); if (!prepared) return Result::failure(prepared.status()); auto started = candidate->play(executionId); if (!started) return Result::failure(started.status()); Slot& previous = entry.slots[entry.active]; - if (previous.player && previous.player->isPlaying()) { + if (previous.player && (previous.player->isPlaying() || previous.player->isFinished())) { auto fading = previous.player->beginBlendOut(blendOut, tick); if (!fading) return Result::failure(fading.status()); } @@ -128,25 +138,178 @@ Result MontageCoordinator::stop(MontageHandle handle, Duration b return player.value().get().beginBlendOut(blendOut, tick); } +Result MontageCoordinator::setLayerBoneMask(std::size_t layer, std::vector weights) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + if (weights.size() != static_cast(skeleton_.getBoneCount())) + return coordinatorError(DiagnosticCode::InvalidArgument, + "montage layer mask must contain one weight per skeleton bone", "weights"); + if (std::any_of(weights.begin(), weights.end(), [](float weight) { + return !std::isfinite(weight) || weight < 0.0f || weight > 1.0f; + })) + return coordinatorError(DiagnosticCode::InvalidArgument, + "montage layer mask weights must be finite and within [0,1]", "weights"); + ensureLayer(layer); + layers_[layer].boneMask = std::move(weights); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::setLayerBoneMask(std::size_t layer, const AnimBoneMask& mask) { + if (mask.getSkeleton() != &skeleton_) + return coordinatorError(DiagnosticCode::InvalidArgument, + "montage layer mask belongs to a different skeleton", "mask"); + std::vector weights; + weights.reserve(static_cast(mask.getBoneCount())); + for (int bone = 0; bone < mask.getBoneCount(); ++bone) weights.push_back(mask.getBoneWeight(bone)); + return setLayerBoneMask(layer, std::move(weights)); +} + +Result MontageCoordinator::clearLayerBoneMask(std::size_t layer) { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + if (layers_[layer].boneMask.empty()) return Result::success(Status::success(StatusCode::NoOp)); + layers_[layer].boneMask.clear(); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result> MontageCoordinator::layerBoneMask(std::size_t layer) const { + if (layer >= layers_.size()) + return coordinatorError>(DiagnosticCode::NotFound, + "montage layer does not exist", "layer"); + return Result>::success(layers_[layer].boneMask); +} + +Result MontageCoordinator::setLayerWeight(std::size_t layer, float weight) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + if (!std::isfinite(weight) || weight < 0.0f || weight > 1.0f) + return coordinatorError(DiagnosticCode::InvalidArgument, + "montage layer weight must be finite and within [0,1]", "weight"); + ensureLayer(layer); + if (layers_[layer].weight == weight) return Result::success(Status::success(StatusCode::NoOp)); + layers_[layer].weight = weight; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::layerWeight(std::size_t layer) const { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + return Result::success(layers_[layer].weight); +} + +Result MontageCoordinator::setLayerAdditive(std::size_t layer, bool additive) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + ensureLayer(layer); + if (layers_[layer].additive == additive) return Result::success(Status::success(StatusCode::NoOp)); + layers_[layer].additive = additive; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::layerAdditive(std::size_t layer) const { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + return Result::success(layers_[layer].additive); +} + +Result MontageCoordinator::setLayerRootMotionReceiver(std::size_t layer, + IMontageRootMotionReceiver& receiver) { + if (layer > static_cast(std::numeric_limits::max() / 2U)) + return coordinatorError(DiagnosticCode::InvalidArgument, "montage layer is too large", "layer"); + ensureLayer(layer); + Layer& entry = layers_[layer]; + entry.rootMotionReceiver = &receiver; + for (Slot& slot : entry.slots) + if (slot.player) slot.player->setRootMotionReceiver(receiver); + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontageCoordinator::clearLayerRootMotionReceiver(std::size_t layer) { + if (layer >= layers_.size()) + return coordinatorError(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); + Layer& entry = layers_[layer]; + if (!entry.rootMotionReceiver) return Result::success(Status::success(StatusCode::NoOp)); + for (Slot& slot : entry.slots) + if (slot.player) slot.player->clearRootMotionReceiver(); + entry.rootMotionReceiver = nullptr; + return Result::success(Status::success(StatusCode::Applied)); +} + +double MontageCoordinator::evaluateLayerPose(Layer& layer) const { + const Slot* first = nullptr; + const Slot* second = nullptr; + double total = 0.0; + for (const Slot& slot : layer.slots) { + if (!slot.player || slot.player->weight() <= 0.0) continue; + total += slot.player->weight(); + if (!first) first = &slot; + else second = &slot; + } + if (!first) { + skeleton_.applyBindPose(layer.rawPose.get()); + return 0.0; + } + + layer.rawPose->copyFrom(&first->player->pose()); + if (second) { + if (total > 0.0) { + layer.blendScratch->copyFrom(layer.rawPose.get()); + layer.rawPose->blendFrom(layer.blendScratch.get(), &second->player->pose(), + static_cast(second->player->weight() / total)); + } + } + return total; +} + Result> MontageCoordinator::pose(std::size_t layerIndex) { if (layerIndex >= layers_.size()) return coordinatorError>(DiagnosticCode::NotFound, "montage layer does not exist", "layer"); Layer& layer = layers_[layerIndex]; + (void)evaluateLayerPose(layer); skeleton_.applyBindPose(layer.pose.get()); - for (const Slot& slot : layer.slots) { - if (!slot.player || slot.player->weight() <= 0.0) continue; - AnimPose base; - base.copyFrom(layer.pose.get()); - layer.pose->blendFrom(&base, &slot.player->pose(), static_cast(slot.player->weight())); + if (layer.boneMask.empty()) { + layer.pose->copyFrom(layer.rawPose.get()); + } else { + for (int bone = 0; bone < layer.pose->getBoneCount(); ++bone) { + const float weight = layer.boneMask[static_cast(bone)]; + layer.pose->local(bone) = blendTRS(layer.pose->local(bone), layer.rawPose->local(bone), weight); + } } return Result>::success(std::ref(*layer.pose)); } +Result> MontageCoordinator::compose(const AnimPose& basePose) { + if (basePose.getBoneCount() != skeleton_.getBoneCount()) + return coordinatorError>( + DiagnosticCode::InvalidArgument, "montage base pose must match the coordinator skeleton", "basePose"); + composedPose_->copyFrom(&basePose); + for (Layer& layer : layers_) { + const double rawWeight = evaluateLayerPose(layer); + const float layerWeight = clampf(static_cast(rawWeight), 0.0f, 1.0f) * layer.weight; + if (layerWeight <= 0.0f) continue; + for (int bone = 0; bone < composedPose_->getBoneCount(); ++bone) { + const float maskWeight = layer.boneMask.empty() ? 1.0f : layer.boneMask[static_cast(bone)]; + const float weight = clampf(layerWeight * maskWeight, 0.0f, 1.0f); + if (weight <= 0.0f) continue; + if (layer.additive) { + applyAdditiveTRS(composedPose_->local(bone), layer.rawPose->local(bone), skeleton_.bindLocal(bone), + weight); + } else { + composedPose_->local(bone) = blendTRS(composedPose_->local(bone), layer.rawPose->local(bone), weight); + } + } + } + composedPose_->computeWorld(&skeleton_); + return Result>::success(std::ref(*composedPose_)); +} + void MontageCoordinator::collectFinished() noexcept { for (Layer& layer : layers_) for (Slot& slot : layer.slots) - if (slot.player && !slot.player->isPlaying()) retireSlot(slot); + if (slot.player && !slot.player->isPlaying() && !slot.player->isBlendingOut() && + slot.player->weight() <= 0.0) + retireSlot(slot); } } // namespace eve::animation diff --git a/src/modules/animation/MontageCoordinator.h b/src/modules/animation/MontageCoordinator.h index 657d75e4..53889429 100644 --- a/src/modules/animation/MontageCoordinator.h +++ b/src/modules/animation/MontageCoordinator.h @@ -12,6 +12,8 @@ namespace eve::animation { +class AnimBoneMask; + struct MontageHandleTag {}; /** @brief Generation-qualified identity owned by one MontageCoordinator. */ using MontageHandle = RuntimeHandle; @@ -47,8 +49,61 @@ public: MontageHandle except = MontageHandle::invalid()); /** @brief Stop one live slot with a continuous blend-out. */ [[nodiscard]] Result stop(MontageHandle handle, Duration blendOut, SimulationTick tick); - /** @brief Borrow the composed layer pose; invalid layers return a structured failure. */ + /** + * @brief Atomically replace one layer's owning per-bone blend weights. + * @param layer Layer index configured independently from montage slot generations. + * @param weights One finite weight in [0,1] for every skeleton bone. + */ + [[nodiscard]] Result setLayerBoneMask(std::size_t layer, std::vector weights); + /** + * @brief Snapshot an existing animation bone mask into one montage layer. + * @param layer Layer index. + * @param mask Borrowed only for this synchronous call; it must target the coordinator skeleton. + */ + [[nodiscard]] Result setLayerBoneMask(std::size_t layer, const AnimBoneMask& mask); + /** @brief Remove one layer's bone mask so subsequent composition affects every bone. */ + [[nodiscard]] Result clearLayerBoneMask(std::size_t layer); + /** @brief Return an owning copy of one configured layer mask. */ + [[nodiscard]] Result> layerBoneMask(std::size_t layer) const; + /** + * @brief Set one layer's finite global influence. + * @param layer Layer index, created on demand. + * @param weight Influence in [0,1]. + * @return Applied, NoOp, or InvalidArgument without partially changing configuration. + */ + [[nodiscard]] Result setLayerWeight(std::size_t layer, float weight); + /** @brief Return one configured layer's global influence, or NotFound for an absent layer. */ + [[nodiscard]] Result layerWeight(std::size_t layer) const; + /** + * @brief Select bind-pose-relative additive or ordered override composition for one layer. + * @param layer Layer index, created on demand. + * @param additive True for additive; false for override. + * @return Applied or NoOp, or InvalidArgument for an unrepresentable layer index. + */ + [[nodiscard]] Result setLayerAdditive(std::size_t layer, bool additive); + /** @brief Return whether one configured layer uses additive composition, or NotFound for an absent layer. */ + [[nodiscard]] Result layerAdditive(std::size_t layer) const; + /** + * @brief Install one root-motion receiver shared by current and future slots of a layer. + * @param layer Layer index. + * @param receiver Receiver that must outlive the coordinator or be explicitly cleared. + * @lifetime The coordinator and its players retain a non-owning observer until clearLayerRootMotionReceiver(). + */ + [[nodiscard]] Result setLayerRootMotionReceiver(std::size_t layer, + IMontageRootMotionReceiver& receiver); + /** @brief Clear the borrowed root-motion receiver from current and future slots of a layer. */ + [[nodiscard]] Result clearLayerRootMotionReceiver(std::size_t layer); + /** @brief Borrow the normalized full layer pose; invalid layers return a structured failure. */ [[nodiscard]] Result> pose(std::size_t layer); + /** + * @brief Compose every configured montage layer over an immediate borrowed base pose. + * @param basePose Base pose for this synchronous evaluation; it is never retained and must match the skeleton. + * @return Borrowed coordinator-owned pose, valid until the next composition or coordinator destruction. + * @details Layers are applied in ascending index order. Slot weights are normalized within each layer; their + * clamped sum, the layer weight, and the optional bone mask determine top-level influence. Additive layers use + * bind-pose-relative deltas. Owner-thread-only, deterministic for identical poses and weights, and non-reentrant. + */ + [[nodiscard]] Result> compose(const AnimPose& basePose); /** @brief Reclaim finished slots and invalidate their handles. */ void collectFinished() noexcept; @@ -62,6 +117,12 @@ private: Slot slots[2]; std::size_t active = 1; std::unique_ptr pose; + std::unique_ptr rawPose; + std::unique_ptr blendScratch; + std::vector boneMask; + float weight = 1.0f; + bool additive = false; + IMontageRootMotionReceiver* rootMotionReceiver = nullptr; }; [[nodiscard]] static std::size_t handleIndex(std::size_t layer, std::size_t slot) noexcept { @@ -73,9 +134,11 @@ private: [[nodiscard]] const Slot* slotFor(MontageHandle handle) const noexcept; void ensureLayer(std::size_t layer); void retireSlot(Slot& slot) noexcept; + [[nodiscard]] double evaluateLayerPose(Layer& layer) const; AnimSkeleton& skeleton_; std::vector layers_; + std::unique_ptr composedPose_; }; } // namespace eve::animation diff --git a/src/modules/animation/MontagePlayer.cpp b/src/modules/animation/MontagePlayer.cpp index 2a8e04d4..2fc36b82 100644 --- a/src/modules/animation/MontagePlayer.cpp +++ b/src/modules/animation/MontagePlayer.cpp @@ -135,6 +135,30 @@ Result MontagePlayer::reloadClips(IMontageClipProvider& provider) { return Result::success(Status::success(StatusCode::Applied)); } +Result MontagePlayer::setSettings(action::ActionMontageSettings settings) { + if (!timeline_) return invalid("montage has not been prepared", "montage"); + auto candidate = *timeline_; + candidate.montage = settings; + auto valid = candidate.validate(); + if (!valid) return Result::failure(valid.status()); + timeline_->montage = std::move(settings); + rootMotionMask_.translationX = timeline_->montage.rootMotionHorizontal; + rootMotionMask_.translationZ = timeline_->montage.rootMotionHorizontal; + rootMotionMask_.translationY = timeline_->montage.rootMotionVertical; + rootMotionMask_.rotation = timeline_->montage.rootMotionRotation; + return Result::success(Status::success(StatusCode::Applied)); +} + +Result MontagePlayer::setSectionSplits(std::vector splitTimestamps) { + if (!timeline_) return invalid("montage has not been prepared", "montage"); + auto candidate = *timeline_; + candidate.splitTimestamps = std::move(splitTimestamps); + auto valid = candidate.validate(); + if (!valid) return Result::failure(valid.status()); + timeline_->splitTimestamps = std::move(candidate.splitTimestamps); + return Result::success(Status::success(StatusCode::Applied)); +} + Result MontagePlayer::replaceClip(std::string_view uri, std::unique_ptr clip) { if (!timeline_) return invalid("montage has not been prepared", "montage"); if (uri.empty() || !clip) return invalid("replacement montage clip is incomplete", "clip"); @@ -280,7 +304,7 @@ Result MontagePlayer::present(const action::ActionAdvance& advan } result.events = advance.timelineEvents; - result.activeBlocks = activeBlocksAt(result.current); + result.activeBlocks = advance.activeBlocks; result.sectionId = activeSection_ ? std::optional(activeSection_->id) : std::nullopt; result.completed = advance.phase == action::ActionPhase::Completed; const double currentSeconds = result.current.seconds(); @@ -328,19 +352,8 @@ std::vector MontagePlayer::realignStateEvents(Durat return events; } -std::vector MontagePlayer::activeBlocksAt(Duration target) const { - std::vector blocks; - for (const auto& track : timeline_->tracks) { - if (track.muted) continue; - for (const auto& state : track.states) { - if (target < state.start || target >= state.end) continue; - blocks.push_back({track.id, state.id, state.type, - Duration::fromNanoseconds(target.nanoseconds() - state.start.nanoseconds()), - Duration::fromNanoseconds(state.end.nanoseconds() - state.start.nanoseconds()), - state.payload}); - } - } - return blocks; +Result> MontagePlayer::activeBlocksAt(Duration target) const { + return timeline_->activeBlocks(target); } Result MontagePlayer::jumpToTime(action::ActionExecutionId executionId, Duration target, @@ -358,7 +371,9 @@ Result MontagePlayer::jumpToTime(action::ActionExecutionId execu result.previous = time_; result.current = target; result.events = realignStateEvents(target); - result.activeBlocks = activeBlocksAt(target); + auto active = activeBlocksAt(target); + if (!active) return Result::failure(active.status()); + result.activeBlocks = std::move(active).takeValue(); player_->stop(); activeSection_ = nullptr; if (const auto* section = sectionAt(target)) { @@ -442,15 +457,28 @@ Result MontagePlayer::interrupt(SimulationTick tick) { Result MontagePlayer::beginBlendOut(Duration duration, SimulationTick tick) { if (duration < Duration::zero()) return invalid("montage blend-out duration must be non-negative", "duration"); - if (duration.isZero()) return interrupt(tick); if (!timeline_) return invalid("montage has not been prepared", "montage"); - if (!playing_) return Result::success(MontageAdvance{}, Status::success(StatusCode::NoOp)); + if (duration.isZero() && playing_) return interrupt(tick); + if (!playing_ && weight_ <= 0.0) + return Result::success(MontageAdvance{}, Status::success(StatusCode::NoOp)); if (hasLastTick_ && tick <= lastTick_) return Result::failure( Diagnostic::error(DiagnosticCode::Conflict, "montage tick must advance monotonically", "tick")); MontageAdvance result; result.previous = result.current = time_; result.weight = weight_; + if (duration.isZero()) { + player_->stop(); + activeSection_ = nullptr; + playing_ = false; + blendingOut_ = false; + weight_ = 0.0; + result.weight = 0.0; + result.completed = true; + lastTick_ = tick; + hasLastTick_ = true; + return Result::success(std::move(result), Status::success(StatusCode::Applied)); + } for (const auto& track : timeline_->tracks) { if (track.muted) continue; for (const auto& state : track.states) diff --git a/src/modules/animation/MontagePlayer.h b/src/modules/animation/MontagePlayer.h index 49986716..9c18fc7d 100644 --- a/src/modules/animation/MontagePlayer.h +++ b/src/modules/animation/MontagePlayer.h @@ -71,15 +71,8 @@ public: virtual void applyMontageRootMotion(const TransformTRS& delta) noexcept = 0; }; -/** @brief Owning per-step update context for one active timeline state block. */ -struct MontageActiveBlock { - LogicalId trackId; - LogicalId itemId; - LogicalId type; - Duration localTime = Duration::zero(); - Duration duration = Duration::zero(); - Value::Object payload; -}; +/** @brief Compatibility name for the canonical action-owned active block projection. */ +using MontageActiveBlock = action::ActionActiveBlock; /** @brief Owning observation returned by one montage presentation step. */ struct MontageAdvance { @@ -126,6 +119,18 @@ public: * @param provider Borrowed synchronous provider; no replacement is committed unless all clips validate. */ [[nodiscard]] Result reloadClips(IMontageClipProvider& provider); + /** + * @brief Apply validated montage-wide settings without rebuilding clip topology or playback time. + * @param settings Candidate settings validated against the prepared timeline before publication. + * @return Applied, or a structured validation/state failure with no observable mutation. + */ + [[nodiscard]] Result setSettings(action::ActionMontageSettings settings); + /** + * @brief Atomically replace objective physical-section split timestamps without rebuilding clip topology. + * @param splitTimestamps Strictly ordered boundaries inside the prepared montage duration. + * @return Applied, or a structured validation/state failure with the previous boundaries preserved. + */ + [[nodiscard]] Result setSectionSplits(std::vector splitTimestamps); /** @brief Transactionally replace one URI-backed clip and preserve the current cursor and slot weight. */ [[nodiscard]] Result replaceClip(std::string_view uri, std::unique_ptr clip); /** @brief Bind presentation playback to one authoritative ActionRuntime execution. */ @@ -184,6 +189,10 @@ public: [[nodiscard]] bool isPlaying() const noexcept { return playing_; } /** @brief Whether playback is in externally requested blend-out. */ [[nodiscard]] bool isBlendingOut() const noexcept { return blendingOut_; } + /** @brief Whether playback completed naturally while retaining its final weighted pose. */ + [[nodiscard]] bool isFinished() const noexcept { + return timeline_ && !executionId_.isZero() && !playing_ && !blendingOut_ && weight_ > 0.0; + } /** @brief Current continuous montage slot weight. */ [[nodiscard]] double weight() const noexcept { return weight_; } /** @brief Authored output layer requested by the prepared montage, or zero before preparation. */ @@ -201,7 +210,7 @@ private: void activate(const action::ActionAnimationSection& section, Duration localTime); void accumulateRootMotion(TransformTRS& total) const; [[nodiscard]] std::vector realignStateEvents(Duration target) const; - [[nodiscard]] std::vector activeBlocksAt(Duration target) const; + [[nodiscard]] Result> activeBlocksAt(Duration target) const; AnimSkeleton& skeleton_; std::unique_ptr player_; diff --git a/src/modules/asset/AssetMigration.cpp b/src/modules/asset/AssetMigration.cpp index 74f11528..1852ff0c 100644 --- a/src/modules/asset/AssetMigration.cpp +++ b/src/modules/asset/AssetMigration.cpp @@ -60,29 +60,36 @@ Result migrateDefinition(std::string_view type, SchemaVersion from, if (from.value() > current.value()) return migrationFailure(DiagnosticCode::UnknownVersion, "asset definition is newer than this reader", std::string(type)); + if (type == "eve.mesh" && current.value() == 3 && (from.value() == 1 || from.value() == 2)) { + const auto* object = definition.getIf(); + if (!object || !object->contains("schema") || !object->at("schema").isString() || + object->at("schema").asString() != "eve.mesh" || !object->contains("schemaVersion") || + !object->at("schemaVersion").isInt64() || + object->at("schemaVersion").asInt() != static_cast(from.value())) + return migrationFailure(DiagnosticCode::ParseError, "eve.mesh definition is malformed"); + auto migrated = *object; + if (from.value() == 1) { + if (migrated.contains("texcoordSets") || migrated.contains("colors")) + return migrationFailure(DiagnosticCode::ParseError, "eve.mesh/1 contains reserved metadata"); + if (auto uv = migrated.find("texcoord0"); uv != migrated.end()) { + if (!uv->second.isBool()) + return migrationFailure(DiagnosticCode::ParseError, "invalid legacy UV metadata"); + Value::Array sets; + if (uv->second.asBool()) sets.emplace_back(int64_t(0)); + migrated.erase(uv); + migrated["texcoordSets"] = Value(std::move(sets)); + } + } else if (migrated.contains("colors")) { + return migrationFailure(DiagnosticCode::ParseError, "eve.mesh/2 contains reserved color metadata"); + } + migrated["colors"] = Value(false); + migrated["schemaVersion"] = Value(int64_t(3)); + return Result::success(Value(std::move(migrated))); + } if (from.value() + 1 != current.value()) return migrationFailure(DiagnosticCode::Unsupported, "asset definition is older than the N-1 compatibility window", std::string(type)); - if (type == "eve.mesh" && from.value() == 1 && current.value() == 2) { - const auto* object = definition.getIf(); - if (!object || !object->contains("schema") || !object->at("schema").isString() || - object->at("schema").asString() != "eve.mesh" || !object->contains("schemaVersion") || - !object->at("schemaVersion").isInt64() || object->at("schemaVersion").asInt() != 1 || - object->contains("texcoordSets")) - return migrationFailure(DiagnosticCode::ParseError, "eve.mesh/1 is malformed"); - auto migrated = *object; - if (auto uv = migrated.find("texcoord0"); uv != migrated.end()) { - if (!uv->second.isBool()) - return migrationFailure(DiagnosticCode::ParseError, "invalid legacy UV metadata"); - Value::Array sets; - if (uv->second.asBool()) sets.emplace_back(int64_t(0)); - migrated.erase(uv); - migrated["texcoordSets"] = Value(std::move(sets)); - } - migrated["schemaVersion"] = Value(int64_t(2)); - return Result::success(Value(std::move(migrated))); - } if (type == "eve.material" && from.value() == 1 && current.value() == 2) { const auto* object = definition.getIf(); if (!object || !object->contains("schema") || !object->at("schema").isString() || @@ -158,7 +165,7 @@ Result currentAssetSchemaVersion(std::string_view type) { static const std::map versions = { {"eve.image", 2}, {"eve.texture", 1}, - {"eve.mesh", 2}, + {"eve.mesh", 3}, {"eve.skeleton", 1}, {"eve.animation-clip", 1}, {"eve.skin", 1}, diff --git a/src/modules/asset/CanonicalMesh.cpp b/src/modules/asset/CanonicalMesh.cpp index 3e48283f..6c6b2538 100644 --- a/src/modules/asset/CanonicalMesh.cpp +++ b/src/modules/asset/CanonicalMesh.cpp @@ -22,23 +22,32 @@ float littleFloat(std::span bytes, std::size_t offset) { return std::bit_cast(little32(bytes, offset)); } +void put32(std::vector& out, std::uint32_t value) { + for (int shift = 0; shift < 32; shift += 8) out.push_back(static_cast(value >> shift)); +} + +void putFloat(std::vector& out, float value) { put32(out, std::bit_cast(value)); } + } // namespace Result decodeCanonicalMesh(std::span bytes, const CanonicalMeshLimits& limits) { static constexpr std::uint8_t magic[] = {'E', 'V', 'M', 'E', 'S', 'H', 0}; if (bytes.size() < 24 || !std::equal(std::begin(magic), std::end(magic), bytes.begin()) || - (bytes[7] != 1 && bytes[7] != 2)) + (bytes[7] != 1 && bytes[7] != 2 && bytes[7] != 3)) return failure(DiagnosticCode::ParseError, "canonical mesh header is invalid"); const auto vertexCount = little32(bytes, 8), indexCount = little32(bytes, 12); const auto flags = little32(bytes, 16), countField = little32(bytes, 20); - const bool legacy = bytes[7] == 1; - if (!vertexCount || !indexCount || indexCount % 3 || flags > (legacy ? 3u : 1u) || (legacy && countField)) + const bool legacy = bytes[7] == 1; + const bool colored = bytes[7] == 3 && (flags & 2u); + if (!vertexCount || !indexCount || indexCount % 3 || + flags > (legacy ? 3u : (bytes[7] == 3 ? 3u : 1u)) || (legacy && countField)) return failure(DiagnosticCode::ParseError, "canonical mesh metadata is invalid"); if (vertexCount > limits.maximumVertices || indexCount > limits.maximumIndices) return failure(DiagnosticCode::InvalidArgument, "canonical mesh exceeds limits"); const uint32_t uvCount = legacy ? ((flags & 2u) ? 1u : 0u) : countField; const uint64_t header = 24ull + (legacy ? 0ull : uint64_t(uvCount) * 4); - const uint64_t floatsPerVertex = 3ull + ((flags & 1u) ? 3 : 0) + uint64_t(uvCount) * 2; + const uint64_t floatsPerVertex = + 3ull + ((flags & 1u) ? 3 : 0) + uint64_t(uvCount) * 2 + (colored ? 4 : 0); // Bound the product through the budget before multiplying untrusted counts. const uint64_t indexBytes = uint64_t(indexCount) * 4; if (header > bytes.size() || header > limits.maximumDecodedBytes || @@ -51,6 +60,7 @@ Result decodeCanonicalMesh(std::span byte CanonicalMeshData result; result.positions.reserve(std::size_t(vertexCount) * 3); if (flags & 1u) result.normals.reserve(std::size_t(vertexCount) * 3); + if (colored) result.colors.reserve(std::size_t(vertexCount) * 4); uint32_t previous = 0; for (uint32_t set = 0; set < uvCount; ++set) { const auto id = legacy ? 0u : little32(bytes, 24 + size_t(set) * 4); @@ -83,6 +93,10 @@ Result decodeCanonicalMesh(std::span byte auto texcoords = appendFloats(values, 2); if (!texcoords) return Result::failure(texcoords.status()); } + if (colored) { + auto colors = appendFloats(result.colors, 4); + if (!colors) return Result::failure(colors.status()); + } } result.indices.reserve(indexCount); for (std::uint32_t index = 0; index < indexCount; ++index) { @@ -96,3 +110,52 @@ Result decodeCanonicalMesh(std::span byte } } // namespace eve::asset + +namespace eve::asset { +Result> encodeCanonicalMesh(const CanonicalMeshData& mesh, + const CanonicalMeshLimits& limits) { + if (mesh.positions.empty() || mesh.positions.size() % 3 || mesh.indices.empty() || mesh.indices.size() % 3) + return failure>(DiagnosticCode::InvalidArgument, "canonical mesh arrays are incomplete"); + const auto vertices = mesh.positions.size() / 3; + if ((!mesh.normals.empty() && mesh.normals.size() != mesh.positions.size()) || + (!mesh.colors.empty() && mesh.colors.size() != vertices * 4) || + vertices > limits.maximumVertices || mesh.indices.size() > limits.maximumIndices) + return failure>(DiagnosticCode::InvalidArgument, "canonical mesh arrays exceed limits or do not match"); + const auto finite = [](const std::vector& values) { + return std::all_of(values.begin(), values.end(), [](float value) { return std::isfinite(value); }); + }; + if (!finite(mesh.positions) || !finite(mesh.normals) || !finite(mesh.colors)) + return failure>(DiagnosticCode::InvalidArgument, "canonical mesh attribute is not finite"); + for (const auto& [set, values] : mesh.texcoords) + if (values.size() != vertices * 2 || !finite(values)) + return failure>(DiagnosticCode::InvalidArgument, "canonical mesh UV array is invalid"); + for (auto index : mesh.indices) if (index >= vertices) + return failure>(DiagnosticCode::InvalidArgument, "mesh index exceeds vertices"); + const std::uint64_t byteCount = 24ull + mesh.texcoords.size() * 4ull + + vertices * (12ull + (mesh.normals.empty() ? 0ull : 12ull) + mesh.texcoords.size() * 8ull + + (mesh.colors.empty() ? 0ull : 16ull)) + + mesh.indices.size() * 4ull; + if (byteCount > limits.maximumDecodedBytes || byteCount > std::numeric_limits::max()) + return failure>(DiagnosticCode::InvalidArgument, "canonical mesh exceeds byte budget"); + std::vector out; + out.reserve(static_cast(byteCount)); + const auto put32 = [&out](std::uint32_t value) { + for (int shift = 0; shift < 32; shift += 8) out.push_back(static_cast(value >> shift)); + }; + const auto putFloat = [&put32](float value) { put32(std::bit_cast(value)); }; + out.insert(out.end(), {'E', 'V', 'M', 'E', 'S', 'H', 0, 3}); + put32(static_cast(vertices)); put32(static_cast(mesh.indices.size())); + put32((mesh.normals.empty() ? 0u : 1u) | (mesh.colors.empty() ? 0u : 2u)); + put32(static_cast(mesh.texcoords.size())); + for (const auto& [set, values] : mesh.texcoords) put32(set); + for (std::size_t vertex = 0; vertex < vertices; ++vertex) { + for (int axis = 0; axis < 3; ++axis) putFloat(mesh.positions[vertex * 3 + axis]); + if (!mesh.normals.empty()) for (int axis = 0; axis < 3; ++axis) putFloat(mesh.normals[vertex * 3 + axis]); + for (const auto& [set, values] : mesh.texcoords) { putFloat(values[vertex * 2]); putFloat(values[vertex * 2 + 1]); } + if (!mesh.colors.empty()) + for (int channel = 0; channel < 4; ++channel) putFloat(mesh.colors[vertex * 4 + channel]); + } + for (auto index : mesh.indices) put32(index); + return Result>::success(std::move(out)); +} +} // namespace eve::asset \ No newline at end of file diff --git a/src/modules/asset/CanonicalMesh.h b/src/modules/asset/CanonicalMesh.h index 48c6c3e4..ed2baeb4 100644 --- a/src/modules/asset/CanonicalMesh.h +++ b/src/modules/asset/CanonicalMesh.h @@ -12,6 +12,8 @@ struct CanonicalMeshData { std::vector positions; std::vector normals; std::map> texcoords; + /** @brief Optional tightly packed RGBA vertex colors in linear space. */ + std::vector colors; std::vector indices; }; /** @brief Allocation limits checked before decoding any vertex arrays. UV count is byte-budget bounded. */ @@ -20,12 +22,21 @@ struct CanonicalMeshLimits { std::uint32_t maximumIndices = 12'000'000; std::uint64_t maximumDecodedBytes = 512ull * 1024ull * 1024ull; }; -/** @brief Decode canonical EVMESH binary v1 or v2 into independently owned arrays. +/** + * @brief Encode an owned CPU mesh as canonical EVMESH binary version 3. + * @param mesh Borrowed immutable arrays consumed synchronously. + * @param limits Bounds checked before allocating the output. + * @return Complete binary blob, or a structured validation/budget failure. + * @thread Worker-safe and reentrant; performs no IO or callbacks. + */ +[[nodiscard]] Result> encodeCanonicalMesh( + const CanonicalMeshData& mesh, const CanonicalMeshLimits& limits = {}); +/** @brief Decode canonical EVMESH binary v1, v2, or v3 into independently owned arrays. * @param bytes Borrowed immutable input valid for this synchronous call only. * @param limits Bounds checked before allocating staging data. * @return Complete snapshot, or a checked format/range/budget failure; no partial result escapes. * @thread Worker-safe and reentrant; no IO, callbacks, backend calls or global mutation. - * @details v1 is compatibility-only. Both versions decode to the same canonical multi-UV snapshot. + * @details v1 and v2 are compatibility-only. v3 adds an optional linear RGBA vertex-color stream. */ [[nodiscard]] Result decodeCanonicalMesh(std::span bytes, const CanonicalMeshLimits& limits = {}); diff --git a/src/modules/asset/graphics/EvpackGraphicsLoader.cpp b/src/modules/asset/graphics/EvpackGraphicsLoader.cpp index 01797e68..5616a360 100644 --- a/src/modules/asset/graphics/EvpackGraphicsLoader.cpp +++ b/src/modules/asset/graphics/EvpackGraphicsLoader.cpp @@ -12,7 +12,9 @@ Result EvpackGraphicsLoader::loadMesh(const AssetRef& const asset::EvpackCapabilities& capabilities, const GraphicsAssetLoadLimits& limits, std::uint32_t texcoordSet, bool preserveAllTexcoords) const { - auto payload = reader_.read(asset, "eve.mesh/2", capabilities, limits.maximumDecodedBytes); + auto payload = reader_.read(asset, "eve.mesh/3", capabilities, limits.maximumDecodedBytes); + if (!payload && payload.error()->code() == DiagnosticCode::TypeMismatch) + payload = reader_.read(asset, "eve.mesh/2", capabilities, limits.maximumDecodedBytes); if (!payload && payload.error()->code() == DiagnosticCode::TypeMismatch) payload = reader_.read(asset, "eve.mesh/1", capabilities, limits.maximumDecodedBytes); if (!payload) return Result::failure(payload.status()); @@ -35,11 +37,18 @@ Result EvpackGraphicsLoader::loadMesh(const AssetRef& if (uv == staging.value().texcoords.end() && (texcoordSet != 0 || !staging.value().texcoords.empty())) return failure(DiagnosticCode::NotFound, "requested UV set is absent; no remapping performed"); - auto uploaded = factory_.uploadMesh( - staging.value().positions.data(), staging.value().normals.empty() ? nullptr : staging.value().normals.data(), - uv == staging.value().texcoords.end() ? nullptr : uv->second.data(), - static_cast(staging.value().positions.size() / 3), staging.value().indices.data(), - static_cast(staging.value().indices.size())); + auto uploaded = staging.value().colors.empty() + ? factory_.uploadMesh(staging.value().positions.data(), + staging.value().normals.empty() ? nullptr : staging.value().normals.data(), + uv == staging.value().texcoords.end() ? nullptr : uv->second.data(), + static_cast(staging.value().positions.size() / 3), staging.value().indices.data(), + static_cast(staging.value().indices.size())) + : factory_.uploadMeshColored(staging.value().positions.data(), + staging.value().normals.empty() ? nullptr : staging.value().normals.data(), + uv == staging.value().texcoords.end() ? nullptr : uv->second.data(), + staging.value().colors.data(), + static_cast(staging.value().positions.size() / 3), staging.value().indices.data(), + static_cast(staging.value().indices.size())); if (!uploaded) return Result::failure(uploaded.status()); if (preserveAllTexcoords) for (const auto& [set, values] : staging.value().texcoords) { diff --git a/src/modules/asset/graphics/EvpackGraphicsLoader.h b/src/modules/asset/graphics/EvpackGraphicsLoader.h index 9960c211..ad6f552f 100644 --- a/src/modules/asset/graphics/EvpackGraphicsLoader.h +++ b/src/modules/asset/graphics/EvpackGraphicsLoader.h @@ -46,6 +46,11 @@ public: const float* posXYZ, const float* nrmXYZ, const float* uvST, int vertexCount, const std::uint32_t* indices, int indexCount) override; + /** @copydoc graphics::IMeshResourceFactory::uploadMeshColored */ + [[nodiscard]] Result uploadMeshColored( + const float* posXYZ, const float* nrmXYZ, const float* uvST, const float* colorRGBA, + int vertexCount, const std::uint32_t* indices, int indexCount) override; + /** * @copydoc graphics::IMeshResourceFactory::releaseMesh * @param mesh Borrowed factory-owned mesh, observed only for this call. @@ -69,7 +74,7 @@ public: : reader_(reader), factory_(factory) {} /** - * @brief Validate and upload one canonical mesh v2 (or compatibility v1) atomically. + * @brief Validate and upload one canonical mesh v3 (or compatibility v1/v2) atomically. * @param texcoordSet UV channel to upload to the existing single-UV backend. Missing channels fail, * except channel 0 on an untextured mesh. Other channels remain available through decodeCanonicalMesh. * @thread Graphics thread; inputs are borrowed for this call, no callbacks or retained staging pointers. diff --git a/src/modules/asset/graphics/GraphicsMeshFactoryAdapter.cpp b/src/modules/asset/graphics/GraphicsMeshFactoryAdapter.cpp index 70125f6c..3ab94ac8 100644 --- a/src/modules/asset/graphics/GraphicsMeshFactoryAdapter.cpp +++ b/src/modules/asset/graphics/GraphicsMeshFactoryAdapter.cpp @@ -25,6 +25,17 @@ Result GraphicsMeshFactoryAdapter::uploadMesh( return Result::success(mesh); } +Result GraphicsMeshFactoryAdapter::uploadMeshColored( + const float* posXYZ, const float* nrmXYZ, const float* uvST, const float* colorRGBA, + int vertexCount, const std::uint32_t* indices, int indexCount) { + graphics::Mesh* mesh = factory_.newMeshFromArraysColored( + posXYZ, nrmXYZ, uvST, colorRGBA, vertexCount, indices, indexCount); + if (!mesh) + return failure(DiagnosticCode::Failed, + "graphics backend rejected colored canonical mesh upload"); + return Result::success(mesh); +} + Result GraphicsMeshFactoryAdapter::releaseMesh(graphics::Mesh* mesh) { if (!mesh) return failure(DiagnosticCode::InvalidArgument, diff --git a/src/modules/asset/procgen/GtsTerrainLodPackage.cpp b/src/modules/asset/procgen/GtsTerrainLodPackage.cpp new file mode 100644 index 00000000..6506e275 --- /dev/null +++ b/src/modules/asset/procgen/GtsTerrainLodPackage.cpp @@ -0,0 +1,89 @@ +#include "asset/procgen/GtsTerrainLodPackage.h" + +#include "asset/CanonicalMesh.h" +#include "asset/import/ImportCommon.h" + +#include +#include + +namespace eve::asset_procgen { +namespace { +template Result fail(DiagnosticCode code, std::string message) { + return Result::failure(Diagnostic::error(code, std::move(message), {}, {}, "asset.procgen.gts-lod")); +} +Value::Array vec3(float x, float y, float z) { return {Value(double(x)), Value(double(y)), Value(double(z))}; } +} + +Result prepareGtsTerrainLodPackage( + const asset_import::ImportPackageIdentity& package, const procgen::GtsTerrainLodSet& lods, + const std::string& terrainName, const asset_import::AssetImportLimits& limits) { + using namespace asset_import; + auto manifest = asset_import::detail::baseManifest(package, "eve.gts-terrain-lod"); + if (!manifest) return Result::failure(manifest.status()); + auto plan = procgen::planGtsTerrainLodAssets(lods, terrainName, "Meshes"); + if (!plan) return Result::failure(plan.status()); + if (plan.value().empty()) return fail(DiagnosticCode::InvalidArgument, "GTS LOD set has no meshes"); + if (plan.value().size() > limits.maximumAssets) + return fail(DiagnosticCode::InvalidArgument, "GTS LOD asset count exceeds limit"); + PreparedAssetImport result; + result.manifest = std::move(manifest).takeValue(); + std::uint64_t totalBytes = 0; + for (const auto& entry : plan.value()) { + const auto* tile = lods.tileAt(entry.tileIndex); + if (!tile || entry.levelIndex < 0 || entry.levelIndex >= static_cast(tile->levels.size())) + return fail(DiagnosticCode::InvalidArgument, "GTS LOD plan references a missing mesh"); + const auto& mesh = tile->levels[static_cast(entry.levelIndex)]; + asset::CanonicalMeshData canonical; + canonical.positions = mesh.positions(); canonical.normals = mesh.normals(); canonical.indices = mesh.indices(); + if (mesh.hasVertexColors()) canonical.colors = mesh.colors(); + if (!mesh.uvs().empty()) canonical.texcoords.emplace(0, mesh.uvs()); + asset::CanonicalMeshLimits meshLimits; + meshLimits.maximumVertices = limits.maximumVerticesPerPrimitive; + meshLimits.maximumIndices = limits.maximumIndicesPerPrimitive; + meshLimits.maximumDecodedBytes = limits.maximumDecodedBytes; + auto blob = asset::encodeCanonicalMesh(canonical, meshLimits); + if (!blob) return Result::failure(blob.status()); + if (blob.value().size() > limits.maximumDecodedBytes || totalBytes > limits.maximumDecodedBytes - blob.value().size()) + return fail(DiagnosticCode::InvalidArgument, "GTS LOD package exceeds decoded byte limit"); + totalBytes += blob.value().size(); + const auto id = package.packageId.child("gts-terrain:" + std::to_string(entry.tileIndex) + ":lod:" + + std::to_string(entry.levelIndex)); + auto ref = asset_import::detail::assetRef(id); + if (!ref) return Result::failure(ref.status()); + const std::string base = "assets/" + id.format() + "/"; + const std::string definitionPath = base + "asset.json", blobPath = base + "mesh.bin"; + const auto& positions = mesh.positions(); + float minX=positions[0], minY=positions[1], minZ=positions[2], maxX=minX, maxY=minY, maxZ=minZ; + for (std::size_t i=0;i::failure(json.status()); + std::vector jsonBytes(json.value().begin(),json.value().end()); + result.manifest.assets.push_back({ref.value(),"eve.mesh",SchemaVersion(3),definitionPath, + asset_import::detail::sha256(jsonBytes),{"mesh","terrain","gts-lod",entry.meshName}}); + result.entries.push_back({definitionPath,std::move(jsonBytes)}); + result.entries.push_back({blobPath,std::move(blob).takeValue()}); + const std::string key="tile-"+std::to_string(entry.tileIndex)+"-lod-"+std::to_string(entry.levelIndex); + result.manifest.entrypoints.emplace(key,ref.value()); + if (!result.manifest.entrypoints.contains("default")) result.manifest.entrypoints.emplace("default",ref.value()); + auto mapping=asset_import::detail::assetRef(id); if(!mapping) return Result::failure(mapping.status()); + result.sourceMappings.push_back({entry.objectName,std::move(mapping).takeValue()}); + result.findings.push_back({terrainName,entry.objectName,ImportDisposition::Translated, + "GTS tile LOD converted to canonical eve.mesh/3 as "+entry.meshName}); + } + auto report=asset_import::detail::finalizeImportReport(result,package,"Unity Pcg Pro GTS","4.2.2", + {{"terrainName",terrainName},{"tileCount",int64_t(lods.getTileCount())},{"lodCount",int64_t(lods.getLevelCount())}}); + if (!report) return Result::failure(report.status()); + auto verified=asset::buildEvaArchive(result.manifest,result.entries); + if (!verified) return Result::failure(verified.status()); + return Result::success(std::move(result)); +} +} \ No newline at end of file diff --git a/src/modules/asset/procgen/GtsTerrainLodPackage.h b/src/modules/asset/procgen/GtsTerrainLodPackage.h new file mode 100644 index 00000000..829f24ec --- /dev/null +++ b/src/modules/asset/procgen/GtsTerrainLodPackage.h @@ -0,0 +1,21 @@ +#pragma once + +/** @file GtsTerrainLodPackage.h @brief Canonical asset packaging for generated GTS terrain LODs. */ + +#include "asset/import/AssetImporter.h" +#include "procgen/GtsTerrainLod.h" + +namespace eve::asset_procgen { +/** + * @brief Prepare a complete `.eva` source package containing every non-empty GTS tile LOD as `eve.mesh/2`. + * @param package Stable caller-owned package identity copied into the result. + * @param lods Borrowed immutable generated LOD set consumed synchronously. + * @param terrainName Filesystem-safe source terrain name used for audit mappings and mesh labels. + * @param limits Allocation and output limits applied before candidate publication. + * @return Owned import candidate ready for `buildEvaArchive`, or a structured failure with no partial result. + * @thread Worker-safe; performs no filesystem, graphics, script or callback operation. + */ +[[nodiscard]] Result prepareGtsTerrainLodPackage( + const asset_import::ImportPackageIdentity& package, const procgen::GtsTerrainLodSet& lods, + const std::string& terrainName, const asset_import::AssetImportLimits& limits = {}); +} \ No newline at end of file diff --git a/src/modules/audio/Audio.cpp b/src/modules/audio/Audio.cpp index 83b51b4a..031e8c83 100644 --- a/src/modules/audio/Audio.cpp +++ b/src/modules/audio/Audio.cpp @@ -2,8 +2,11 @@ #include "Source.h" #include "AudioCapabilities.h" +#include "AudioZone.h" +#include "UnderwaterAudio.h" #include "common/Exception.h" #include "common/Profile.h" +#include "common/SquirrelBinding.h" #include "common/StartupTiming.h" #include "sound/Decoder.h" #include "sound/Sound.h" @@ -161,6 +164,11 @@ void Audio::stopAll() { } } +int Audio::getSourceCount() { + std::lock_guard lock(mutex); + return static_cast(allSources.size()); +} + void Audio::setVolume(float v) { masterVolume = std::max(0.f, v); alListenerf(AL_GAIN, masterVolume); @@ -209,6 +217,41 @@ void Audio::expose(ssq::Table &table) { src.addFunc("setDirection", &Source::setDirection); src.addFunc("setRelative", &Source::setRelative); src.addFunc("setAttenuationDistances", &Source::setAttenuationDistances); + table.addFunc("applyUnderwaterAudio", + [vm = table.getHandle()](Source* down, Source* up, Source* ambience, + bool playDown, bool playUp, bool loopAmbience, + float volume) { + return eve::script::projectResult( + vm, applyUnderwaterAudio(down, up, ambience, playDown, playUp, loopAmbience, volume)); + }); + auto zoneItem=table.addClass("AudioZoneItem",ssq::Class::Ctor()); + zoneItem.addVar("volume",&AudioZoneItem::volume); zoneItem.addVar("fadeInTime",&AudioZoneItem::fadeInTime); + zoneItem.addVar("fadeOutTime",&AudioZoneItem::fadeOutTime); zoneItem.addVar("duration",&AudioZoneItem::duration); + auto zoneProfile=table.addClass("AudioZoneProfile",ssq::Class::Ctor()); + zoneProfile.addVar("x",&AudioZoneProfile::x); zoneProfile.addVar("y",&AudioZoneProfile::y); + zoneProfile.addVar("z",&AudioZoneProfile::z); zoneProfile.addVar("radius",&AudioZoneProfile::radius); + zoneProfile.addVar("minimumBreakTime",&AudioZoneProfile::minimumBreakTime); + zoneProfile.addVar("maximumBreakTime",&AudioZoneProfile::maximumBreakTime); + zoneProfile.addVar("deactivationTime",&AudioZoneProfile::deactivationTime); + zoneProfile.addVar("global",&AudioZoneProfile::global); + zoneProfile.addFunc("addItem",[vm=table.getHandle()](AudioZoneProfile* p,const AudioZoneItem* item){ + auto r=p&&item?p->addItem(*item):Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,"profile and item required")); + return eve::script::projectResult(vm,std::move(r),[](int v){return Value(v);}); + }); + zoneProfile.addFunc("getItemCount",[](const AudioZoneProfile* p){return p?p->itemCount():0;}); + auto zoneState=table.addClass("AudioZoneState",ssq::Class::Ctor()); + zoneState.addVar("selectedTrack",&AudioZoneState::selectedTrack); zoneState.addVar("playing",&AudioZoneState::playing); + table.addFunc("evaluateAudioZone",[vm=table.getHandle()](const AudioZoneProfile* p,AudioZoneState* state, + float now,float x,float y,float z,float master,std::uint32_t seed){ + auto r=p&&state?evaluateAudioZone(*p,*state,now,x,y,z,master,seed) + :Result::failure(Diagnostic::error(DiagnosticCode::InvalidArgument,"profile and state required")); + return eve::script::projectResult(vm,std::move(r),[](const AudioZoneOutput& v){return Value::Object{ + {"play",v.play},{"stop",v.stop},{"trackIndex",v.trackIndex},{"volume",v.volume}};}); + }); + table.addFunc("applyAudioZoneOutput",[vm=table.getHandle()](Source* source,bool play,bool stop,float volume){ + AudioZoneOutput output; output.play=play; output.stop=stop; output.volume=volume; + return eve::script::projectResult(vm,applyAudioZoneOutput(source,output)); + }); } void Audio::expose(ssq::Class &cls) { diff --git a/src/modules/audio/Audio.h b/src/modules/audio/Audio.h index e226d8e1..3e75b7d2 100644 --- a/src/modules/audio/Audio.h +++ b/src/modules/audio/Audio.h @@ -47,6 +47,8 @@ public: void stop(Source *s); /** @brief Stops every live source registered with this module. */ void stopAll(); + /** @brief Return the current number of caller- or handler-owned live sources. */ + [[nodiscard]] int getSourceCount(); /** @brief Pauses a source (no-op when s is null). */ void pause(Source *s); diff --git a/src/modules/audio/AudioCapabilities.cpp b/src/modules/audio/AudioCapabilities.cpp index a260349a..057f49cd 100644 --- a/src/modules/audio/AudioCapabilities.cpp +++ b/src/modules/audio/AudioCapabilities.cpp @@ -1,10 +1,66 @@ +#include "action/ActionAudioBlock.h" +#include "action/ActionAudioWaveform.h" +#include "action/ActionParameterCurve.h" +#include "action/ActionNotifyRegistry.h" +#include "action/ActionPreview.h" +#include "action/ActionSpatialBlock.h" +#include "audio/Audio.h" +#include "audio/Source.h" #include "common/AudioQuery.h" #include "common/Capability.h" -#include "audio/Audio.h" +#include "common/EntitySpatialResolver.h" +#include "sound/Sound.h" +#include "sound/SoundData.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace eve::audio { namespace { +std::uint64_t stableAudioSeed(eve::action::ActionExecutionId executionId, + const eve::LogicalId& itemId, std::uint64_t salt = 0) { + std::uint64_t hash = 1469598103934665603ULL ^ executionId.value() ^ salt; + for (const unsigned char value : itemId.format()) { + hash ^= value; + hash *= 1099511628211ULL; + } + return hash; +} + +const std::string& selectAudioUri(const eve::action::ActionAudioBinding& binding, std::uint64_t seed) { + if (binding.randomUris.empty()) return binding.uri; + return binding.randomUris[static_cast(seed % binding.randomUris.size())]; +} + +double selectAudioPitch(const eve::action::ActionAudioBinding& binding, std::uint64_t seed) { + if (binding.randomPitchOffset <= 0.0) return binding.pitch; + constexpr double divisor = static_cast(std::numeric_limits::max()); + const double unit = static_cast(seed) / divisor; + return std::clamp(binding.pitch + (unit * 2.0 - 1.0) * binding.randomPitchOffset, 0.1, 3.0); +} + +void configureAudioSource(Source& source, const eve::action::ActionAudioBinding& binding, + double pitch) { + source.setVolume(static_cast(binding.volume)); + source.setPitch(static_cast(pitch)); + source.setLooping(binding.looping); + source.setRelative(binding.spatialBlend <= 0.01); + source.setAttenuationDistances(static_cast(binding.minDistance), + static_cast(binding.maxDistance)); +} + class AudioQueryImpl final : public eve::IAudioQuery { public: float volume() const override { @@ -21,11 +77,555 @@ public: } }; +class ActionAudioWaveformProvider final : public eve::action::IActionAudioWaveformProvider { +public: + eve::Result waveform( + const eve::action::ActionAudioWaveformRequest& request) override { + if (request.bucketCount == 0 || request.bucketCount > kMaximumBuckets) + return failure(eve::DiagnosticCode::InvalidArgument, + "Audio waveform bucket count is outside the bounded range", "bucketCount"); + if (request.blockDuration <= eve::Duration::zero()) + return failure(eve::DiagnosticCode::InvalidArgument, + "Audio waveform block duration must be positive", "blockDuration"); + if (request.binding.uri.empty()) + return failure(eve::DiagnosticCode::InvalidArgument, + "Audio waveform URI must be non-empty", "uri"); + if (!std::isfinite(request.binding.pitch) || request.binding.pitch <= 0.0) + return failure(eve::DiagnosticCode::InvalidArgument, + "Audio waveform pitch must be positive and finite", "pitch"); + try { + auto* data = eve::sound::Sound::create()->newSoundDataFromFile(request.binding.uri); + if (!data || data->getSampleRate() <= 0 || data->getSampleCount() <= 0 || + (data->getBitDepth() != 8 && data->getBitDepth() != 16) || + (data->getChannelCount() != 1 && data->getChannelCount() != 2)) + return failure(eve::DiagnosticCode::Unsupported, + "Audio waveform requires bounded PCM8 or PCM16 data", "uri"); + eve::ref retained(data); + (void)retained; + eve::action::ActionAudioWaveform result; + result.clipDurationSeconds = data->getDuration(); + result.buckets.resize(request.bucketCount); + const double blockSeconds = request.blockDuration.seconds(); + const auto frameCount = static_cast(data->getSampleCount()); + for (std::size_t bucket = 0; bucket < request.bucketCount; ++bucket) { + const double localStart = blockSeconds * static_cast(bucket) / + static_cast(request.bucketCount); + const double localEnd = blockSeconds * static_cast(bucket + 1) / + static_cast(request.bucketCount); + result.buckets[bucket] = sampleBucket(*data, request.binding, localStart, localEnd, frameCount); + } + return eve::Result::success(std::move(result)); + } catch (const std::exception& error) { + return failure(eve::DiagnosticCode::Failed, error.what(), "uri"); + } + } + +private: + static constexpr std::size_t kMaximumBuckets = 4096; + static constexpr std::size_t kMaximumSamplesPerBucket = 8192; + + static float sampleAt(const eve::sound::SoundData& data, std::size_t frame, int channel) noexcept { + const auto channels = static_cast(data.getChannelCount()); + const auto index = frame * channels + static_cast(channel); + const auto* bytes = static_cast(data.getData()); + if (data.getBitDepth() == 8) + return (static_cast(bytes[index]) - 128.0f) / 128.0f; + std::int16_t sample = 0; + std::memcpy(&sample, bytes + index * sizeof(sample), sizeof(sample)); + return static_cast(sample) / 32768.0f; + } + + static eve::action::ActionAudioWaveformBucket sampleBucket( + const eve::sound::SoundData& data, const eve::action::ActionAudioBinding& binding, + double localStart, double localEnd, std::size_t frameCount) noexcept { + eve::action::ActionAudioWaveformBucket result; + result.minimum = 1.0f; + result.maximum = -1.0f; + const double duration = data.getDuration(); + if (duration <= 0.0) return {}; + double mediaStart = localStart * binding.pitch; + double mediaEnd = localEnd * binding.pitch; + if (!binding.looping && mediaStart >= duration) return {}; + if (binding.looping) { + const double span = std::max(0.0, mediaEnd - mediaStart); + mediaStart = span >= duration ? 0.0 : std::fmod(mediaStart, duration); + mediaEnd = mediaStart + std::min(span, duration); + } else { + mediaStart = std::min(mediaStart, duration); + mediaEnd = std::min(mediaEnd, duration); + } + const double mediaSpan = std::max(0.0, mediaEnd - mediaStart); + const auto estimatedFrames = static_cast( + std::ceil(mediaSpan * static_cast(data.getSampleRate()))); + const std::size_t stride = std::max( + 1, (estimatedFrames + kMaximumSamplesPerBucket - 1) / kMaximumSamplesPerBucket); + const std::size_t samples = std::max( + 1, std::min(estimatedFrames + 1, kMaximumSamplesPerBucket)); + for (std::size_t offset = 0; offset < samples; ++offset) { + double media = mediaStart + static_cast(offset * stride) / + static_cast(data.getSampleRate()); + if (binding.looping) + media = std::fmod(media, duration); + else + media = std::min(media, duration); + const auto frame = std::min(frameCount - 1, static_cast(media * data.getSampleRate())); + for (int channel = 0; channel < data.getChannelCount(); ++channel) { + const float value = sampleAt(data, frame, channel); + result.minimum = std::min(result.minimum, value); + result.maximum = std::max(result.maximum, value); + } + } + if (result.minimum > result.maximum) return {}; + return result; + } + + static eve::Result failure( + eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure( + eve::Diagnostic::error(code, std::move(message), std::move(path))); + } +}; + +class AudioActionParameterSink final : public eve::action::IActionParameterSink { +public: + bool supports(const eve::LogicalId& target) const noexcept override { + return target.format() == "audio:master-volume"; + } + + eve::Result apply(const eve::action::ActionParameterSample& sample) override { + auto* audio = eve::ModuleManager::getInstance