Merge pull request #522 from EVEngine/promote/v0.5.2

release: v0.5.2
This commit is contained in:
西风逍遥游 authored and GitHub committed 2026-10-11 22:49:53 +08:00
commit 5d22975786
883 files changed
+107656 -13457

No files matched your search

+2 -1
View File
@@ -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:
+79
View File
@@ -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
+47 -13
View File
@@ -1,12 +1,13 @@
#!/usr/bin/env bash
# test-sdk.sh <sdk-root> <platform> [expected-version]
# test-sdk.sh <sdk-root> <platform> [expected-version] [android-abi]
#
# Verify a freshly-built target-platform SDK is usable by consumers, before it is
# zipped and published to a GitHub Release. Runs on the same host as the SDK:
# win32 -> bin/eve.exe (Windows runner)
# linux -> bin/eve (Linux runner)
# macosx-> bin/eve (macOS runner)
# android/ios -> skipped (no host runtime binary)
# android -> packaged APK assembled from the installed SDK
# ios -> installed device app bundle validated as an arm64 Mach-O
#
# [expected-version] (e.g. "0.1.0") makes the version checks exact: the
# share/eve/VERSION marker must equal it and `eve -v` must contain it.
@@ -19,7 +20,7 @@
# 4. `eve package --sdk` assembles a runnable game folder containing the runtime
# + game.eve (and, on Windows, the runtime DLLs).
#
# Best-effort (WARN only, never blocks the release): try to run the packaged game
# Best-effort (WARN only, never blocks the release): try to run the desktop packaged game
# from memory and check for the game's startup marker. Window/Vulkan init depends
# on a display / software ICD, so failures there are reported, not fatal.
set -euo pipefail
@@ -33,11 +34,12 @@ case "$SDK" in
esac
PLAT="${2:?usage: test-sdk.sh <sdk-root> <platform> [expected-version]}"
EXPECTED="${3:-}"
ANDROID_PACKAGE_ABI="${4:-arm64-v8a}"
case "$PLAT" in
win32) RUNTIME="eve.exe" ;;
linux|macosx) RUNTIME="eve" ;;
android) RUNTIME="" ;; # verified by the APK smoke below
android|ios) RUNTIME="" ;; # verified by target-specific gates below
*) echo "SKIP: no host runtime to test for platform '$PLAT'"; exit 0 ;;
esac
@@ -50,8 +52,7 @@ trap 'rm -rf "$WORK"' EXIT
# Hard gate: the template + SDK libs + a minimal game must produce an APK.
if [ "$PLAT" = "android" ]; then
if [ -z "${ANDROID_SDK:-}" ] || [ -z "${JAVA_HOME:-}" ]; then
echo "SKIP: android APK smoke needs ANDROID_SDK + JAVA_HOME in the environment"
exit 0
fail "android APK smoke needs ANDROID_SDK + JAVA_HOME in the environment"
fi
[ -f "$SDK/share/eve/TARGET_PLATFORM" ] || fail "missing share/eve/TARGET_PLATFORM"
[ "$(cat "$SDK/share/eve/TARGET_PLATFORM")" = "android" ] || fail "TARGET_PLATFORM != android"
@@ -74,13 +75,15 @@ if [ "$PLAT" = "android" ]; then
fail "android template launcher must be EVEngineActivity (EVTestActivity has MAIN)"
fi
JNI="$TEMPLATE/app/src/main/jniLibs/arm64-v8a"
JNI="$TEMPLATE/app/src/main/jniLibs/$ANDROID_PACKAGE_ABI"
mkdir -p "$JNI"
[ -f "$SDK/lib/libmain.so" ] || fail "missing lib/libmain.so"
cp "$SDK/lib/libmain.so" "$JNI/"
for lib in libSDL2.so libhidapi.so libc++_shared.so; do
[ -f "$SDK/lib/$lib" ] && cp "$SDK/lib/$lib" "$JNI/"
for lib in libSDL2.so libc++_shared.so; do
[ -f "$SDK/lib/$lib" ] || fail "missing lib/$lib"
cp "$SDK/lib/$lib" "$JNI/"
done
[ ! -f "$SDK/lib/libhidapi.so" ] || cp "$SDK/lib/libhidapi.so" "$JNI/"
GAME="$TEMPLATE/app/src/main/assets/game"
mkdir -p "$GAME"
@@ -94,10 +97,9 @@ EOF
printf 'sdk.dir=%s\n' "$ANDROID_SDK" > "$TEMPLATE/local.properties"
echo "== [test-sdk] assembling APK from $TEMPLATE =="
( cd "$TEMPLATE" && sh gradlew :app:assembleDebug --no-daemon --console=plain ) \
>"$WORK/apk-build.log" 2>&1
rc=$?
if [ $rc -ne 0 ]; then
if ! ( cd "$TEMPLATE" && bash gradlew :app:assembleDebug \
-PevengineAbi="$ANDROID_PACKAGE_ABI" --no-daemon --console=plain ) \
>"$WORK/apk-build.log" 2>&1; then
tail -n 40 "$WORK/apk-build.log"
fail "gradle assembleDebug failed"
fi
@@ -108,6 +110,38 @@ EOF
exit 0
fi
# --- iOS: validate the installed device SDK app bundle -----------------------
# The release SDK contains an iphoneos app and cannot run in a simulator. The
# separate ios-sim workflow job is the runtime gate; this is the artifact gate
# that catches a missing executable, wrong architecture, malformed bundle, or
# version/target marker before the SDK is zipped.
if [ "$PLAT" = "ios" ]; then
[ -f "$SDK/share/eve/TARGET_PLATFORM" ] || fail "missing share/eve/TARGET_PLATFORM"
[ "$(cat "$SDK/share/eve/TARGET_PLATFORM")" = "ios" ] || fail "TARGET_PLATFORM != ios"
[ -f "$SDK/share/eve/VERSION" ] || fail "missing share/eve/VERSION"
VERSION_FILE="$(cat "$SDK/share/eve/VERSION")"
if [ -n "$EXPECTED" ] && [ "$VERSION_FILE" != "$EXPECTED" ]; then
fail "share/eve/VERSION '$VERSION_FILE' != expected '$EXPECTED'"
fi
APP="$SDK/bin/eve.app"
PLIST="$APP/Info.plist"
[ -d "$APP" ] || fail "ios SDK missing bin/eve.app"
[ -f "$PLIST" ] || fail "ios SDK missing eve.app/Info.plist"
command -v /usr/libexec/PlistBuddy >/dev/null 2>&1 || fail "PlistBuddy unavailable"
EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$PLIST")"
[ -n "$EXECUTABLE" ] || fail "CFBundleExecutable is empty"
BINARY="$APP/$EXECUTABLE"
[ -f "$BINARY" ] || fail "ios SDK missing app executable '$EXECUTABLE'"
file "$BINARY" | grep -q 'Mach-O' || fail "ios app executable is not Mach-O"
lipo -archs "$BINARY" | tr ' ' '\n' | grep -qx arm64 || fail "ios app executable has no arm64 slice"
[ -d "$SDK/include/eve" ] || fail "ios SDK missing include/eve"
[ -d "$SDK/platform" ] || fail "ios SDK missing platform template"
echo "OK: ios device SDK bundle is complete (executable=$EXECUTABLE, arch=arm64)"
echo "== [test-sdk] PASS (ios) =="
exit 0
fi
echo "== [test-sdk] platform=$PLAT sdk=$SDK runtime=$RUNTIME =="
# --- 1. SDK layout + markers ---------------------------------------------------
+61 -24
View File
@@ -151,6 +151,14 @@ jobs:
timeout-minutes: 5
run: python3 scripts/check_test_manifest.py
- name: Check examples layout and overview registration
# Every examples/<name>/ is either a runnable example (main.nut +
# config.nut) or explicitly exempted with a reason, owns a README.md and
# is listed in examples/README.md. A new example therefore cannot dodge
# the runtime smoke test in the Linux lane by simply not being runnable.
timeout-minutes: 5
run: python3 scripts/check_examples.py
- name: Check critical return-value diagnostics
timeout-minutes: 5
run: python3 scripts/check_nodiscard.py
@@ -174,7 +182,10 @@ jobs:
- name: Test CI and repository scripts
timeout-minutes: 10
run: python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
# -X utf8 so the runner's own output never follows the host ANSI code
# page; on Linux CI this is already the default. Test modules are
# imported by the runner, so they cannot guard their own stdio.
run: python3 -X utf8 -m unittest discover -s scripts/tests -p "test_*.py" -v
profile-matrix:
name: Profile ${{ matrix.profile }}
@@ -277,9 +288,9 @@ jobs:
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.windows == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
runs-on: windows-latest
# This lane builds and tests both Vulkan and native Dawn configurations.
# A cold Windows runner needs more than 90 minutes for the two independent
# unit_test builds; keep the per-step limits below as the hang guards.
timeout-minutes: 180
# Release run 35436382201 exhausted the former 180-minute job budget while
# the bounded native Dawn build step was still making progress.
timeout-minutes: 300
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64
@@ -359,10 +370,9 @@ jobs:
- name: Build win32 debug
# A PR that changes the third-party patch/cache key must do a full cold
# dependency plus engine/test build. Recent windows-2025 runners can
# exceed 40 minutes without any compiler failure, so retain a bounded
# hang guard while allowing the cold build to finish.
timeout-minutes: 60
# dependency plus engine/test build. Release run 35419568563 was still
# compiling normally when the former 60-minute guard terminated it.
timeout-minutes: 100
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -520,7 +530,7 @@ jobs:
# A cold Release build took 55 minutes and the process-isolated 3510-case
# suite reached 3060/3510 at the former 30-minute test guard. Preserve
# bounded hang detection while budgeting setup, build, and the full suite.
timeout-minutes: 120
timeout-minutes: 240
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-windows-x64-release
@@ -566,7 +576,9 @@ jobs:
run: bash .github/scripts/setup-software-vulkan.sh
- name: Build win32 release
timeout-minutes: 90
# Cold release builds reached the former 90- and 120-minute guards
# without a compiler failure in runs 35419568563 and 35436382201.
timeout-minutes: 160
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
@@ -589,10 +601,9 @@ jobs:
needs: [source-quality, change-scope]
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.android == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
runs-on: windows-latest
# A cold NDK build can consume almost the entire 45-minute build-step budget
# before Gradle packages the finished native library. Keep the step timeout
# below as the hang guard while leaving enough job budget for setup/cleanup.
timeout-minutes: 90
# Release run 35436382201 reached 1363/1408 with no compiler failures at
# the former 60-minute build guard. Leave setup/cleanup headroom as well.
timeout-minutes: 120
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-debug
@@ -620,6 +631,10 @@ jobs:
with:
version: "v0.16.0"
- name: Start C/C++ compilation cache
timeout-minutes: 5
run: sccache --start-server
- name: Setup Java 17
timeout-minutes: 10
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
@@ -650,7 +665,7 @@ jobs:
test -f "$ANDROID_NDK/build/cmake/android.toolchain.cmake"
- name: Build android debug
timeout-minutes: 60
timeout-minutes: 85
run: |
test -f "$ANDROID_NDK/build/cmake/android.toolchain.cmake"
make build/android-debug \
@@ -670,7 +685,9 @@ jobs:
needs: [source-quality, change-scope]
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.android == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
runs-on: windows-latest
timeout-minutes: 60
# The release build step alone may take up to 90 minutes on a cold runner.
# Leave room for toolchain setup and cache reporting at the job level.
timeout-minutes: 150
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-android-arm64-release
@@ -698,6 +715,10 @@ jobs:
with:
version: "v0.16.0"
- name: Start C/C++ compilation cache
timeout-minutes: 5
run: sccache --start-server
- name: Setup Java 17
timeout-minutes: 10
uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
@@ -746,7 +767,9 @@ jobs:
needs: [source-quality, change-scope]
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.macos == 'true' && (github.event_name != 'workflow_call' || inputs.build_type == 'debug' || inputs.build_type == 'both') }}
runs-on: macos-latest
timeout-minutes: 120
# The debug unit suite exceeded its former 30-minute step guard in release
# run 35566503055. Budget the suite and the separate Dawn lane together.
timeout-minutes: 240
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-debug
@@ -785,7 +808,9 @@ jobs:
run: bash .github/scripts/resolve-vulkan-macos.sh "${{ runner.temp }}/VulkanSDK"
- name: Build macosx debug
timeout-minutes: 40
# A cold build reached 96% of unit_test before the former 40-minute
# guard terminated release run 35419568563.
timeout-minutes: 75
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: >-
@@ -793,7 +818,7 @@ jobs:
CMAKE_EXTRA_ARGS="-DEVENGINE_COMPILER_CACHE=SCCACHE"
- name: Run macosx unit tests
timeout-minutes: 30
timeout-minutes: 60
env:
ALSOFT_DRIVERS: "null"
run: make test/macosx-debug
@@ -851,7 +876,9 @@ jobs:
fi
- name: Build native Dawn Metal lane
timeout-minutes: 40
# A cold Dawn/Tint build was still progressing with zero compiler
# failures at the former 40-minute guard in release run 35436382201.
timeout-minutes: 75
run: cmake --build build/webgpu-macos --target unit_test -j "$(sysctl -n hw.ncpu)"
- name: Report C/C++ compilation cache
@@ -873,7 +900,9 @@ jobs:
needs: [source-quality, change-scope]
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.macos == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
runs-on: macos-latest
timeout-minutes: 60
# The 75-minute build and 30-minute test guards must both fit inside the
# job budget, together with checkout and toolchain setup.
timeout-minutes: 150
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-macos-arm64-release
@@ -1228,10 +1257,14 @@ jobs:
ctest --test-dir build/linux-debug --output-on-failure -j 2 \
-R '^bundle/(graphics_font|TextureSampler|gpgpu|tensor|virtualgeometry_gpu|graphics_headless)\.cpp$'
- name: Smoke all examples (headless)
- name: Smoke all examples (headless, >=2s alive each)
timeout-minutes: 20
env:
ALSOFT_DRIVERS: "null"
# Contract for examples/README.md "示例规范": every runnable example
# must still be running MIN_RUN_SECONDS after launch, with no error
# markers in its log; dying early or exiting non-zero fails the job.
MIN_RUN_SECONDS: "2"
run: |
xvfb-run -a -s "-screen 0 1280x800x24" bash scripts/smoke_examples.sh
@@ -1247,7 +1280,9 @@ jobs:
needs: [source-quality, change-scope]
if: ${{ needs.source-quality.result == 'success' && needs.change-scope.outputs.linux == 'true' && (inputs.build_type == 'release' || inputs.build_type == 'both') }}
runs-on: ubuntu-24.04
timeout-minutes: 60
# The 75-minute build and 30-minute test guards must both fit inside the
# job budget, together with checkout and toolchain setup.
timeout-minutes: 150
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-release
@@ -1443,7 +1478,9 @@ jobs:
needs: [source-quality]
if: ${{ needs.source-quality.result == 'success' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') }}
runs-on: ubuntu-24.04
timeout-minutes: 90
# The build plus half of the coverage test suite consumed the former
# 90-minute job budget in release run 35436382201.
timeout-minutes: 150
env:
SCCACHE_GHA_ENABLED: "true"
SCCACHE_GHA_VERSION: evengine-cpp-v1-linux-x64-coverage
+61 -33
View File
@@ -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
+30 -2
View File
@@ -56,8 +56,10 @@ export ALSOFT_DRIVERS=null # OpenAL Soft nul
`docs/dev/superpowers/specs/2026-08-18-test-suite-optimization.md`.
- Run an example (long-lived GUI loop — use `timeout` for a smoke run):
`cd examples/basic && VK_ICD_FILENAMES=... ALSOFT_DRIVERS=... XDG_RUNTIME_DIR=... xvfb-run -a ../../build/linux-debug/src/engine/eve run`
- `scripts/smoke_examples.sh <name>` launches an example headless and greps for error
markers (PASS/FAIL) — a good quick check under `xvfb-run`.
- `scripts/smoke_examples.sh <name>` launches an example headless, requires it to stay
alive for `MIN_RUN_SECONDS` (default 2s) and greps for error markers (PASS/FAIL) — a
good quick check under `xvfb-run`. `python3 scripts/check_examples.py` (or
`make check/examples`) validates the `examples/` layout half of the same contract.
- The benign `[ALSOFT] Failed to set real-time priority` warning is expected on this VM.
### Capturing a rendered frame (visual artifact)
@@ -269,6 +271,32 @@ model behind them.
- **Tests stay per-module.** New tests go into their own file under `test/`
(zeroerr cases; each case is process-isolated via CTest). Do not grow a
shared test main.
- **Examples are a tested surface.** A new `examples/<name>/` must be runnable
(`main.nut` + a `config.nut` that assigns the engine `config` table), or be
listed with a reason in `scripts/check_examples.py`'s `NON_RUNNABLE_EXAMPLES`;
it must own a `README.md` and a row in `examples/README.md`. `make check/examples`
(source-quality) enforces that layout, and `scripts/smoke_examples.sh` launches
every runnable example in CI for at least `MIN_RUN_SECONDS` (2s) — dying early,
exiting non-zero, or printing an error marker fails the job. Run
`MIN_RUN_SECONDS=2 bash scripts/smoke_examples.sh <name>` before claiming an
example works.
- **Repository scripts are UTF-8 on every host.** CI runs them on Linux, where
stdout/stderr are UTF-8; on Windows a *redirected* stream falls back to the
ANSI code page (cp936/GBK on a Chinese Windows), so printing a character that
code page cannot encode (`↔`, `²`, `³`, `⇒`, `•`, emoji — all common in this
repository) raises `UnicodeEncodeError` and turns a checker into a traceback.
The mirror image is input: `Path.read_text()` without `encoding=` decodes
UTF-8 repository text as cp936 and raises `UnicodeDecodeError`.
- Every `scripts/*.py` program (a module with an `if __name__ == "__main__":`
block) calls `utf8_stdio.enable_utf8_stdio()` as the first statement of that
block — never at import time, so importing a script module stays free of
side effects.
- Text file I/O always passes `encoding="utf-8"` explicitly.
- Decode a child process with the encoding that child actually writes: `git`
output is UTF-8, so its call sites pass `encoding="utf-8"`.
- `scripts/tests/test_utf8_stdio.py` enforces all of the above and runs in the
CI script-test step; `python3 -X utf8 -m unittest discover -s scripts/tests
-p "test_*.py"` is the local equivalent.
- **PR granularity.** An interface change ships as one PR that updates the
interface, every backend and every consumer — no intermediate commits that
break CI. Use `codex/` branch prefixes for agent work.
+1 -1
View File
@@ -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}")
+19 -3
View File
@@ -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)
+31 -8
View File
@@ -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)
+16 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+10 -3
View File
@@ -54,7 +54,7 @@ eve_declare_module(NAME level_editing LAYER 5
DEPS editing
GROUP 2d 3d web)
eve_declare_module(NAME particles LAYER 5 SCRIPT Particles SLOT particles
DEPS animation data filesystem graphics ik
DEPS action animation data filesystem graphics ik
THIRDPARTY poco
GROUP 2d 3d)
# Surface fluid simulation: particles constrained to mesh SDFs (flow down
@@ -243,13 +243,20 @@ eve_declare_module(NAME pixelworld_editor LAYER 6 SCRIPT PixelWorldEditorModule
GROUP 2d 3d web)
# Runtime bridge from capability-selected packages into executable PointGraphs.
eve_declare_module(NAME asset_procgen LAYER 6
DEPS asset procgen
DEPS asset asset_import data procgen
GROUP 3d)
eve_declare_module(NAME procgen_physics LAYER 6 SCRIPT ProcgenPhysics SLOT procgenPhysics
DEPS physics procgen
GROUP 3d)
eve_declare_module(NAME procgen_animation DIR procgen/animation LAYER 6
SCRIPT ProcgenAnimation SLOT procgenAnimation
DEPS animation procgen
GROUP 3d)
eve_declare_module(NAME snow LAYER 6 SCRIPT Snow SLOT snow
DEPS graphics procgen
GROUP 3d)
eve_declare_module(NAME sceneloader LIB EVSceneLoader LAYER 6 SCRIPT SceneLoader
DEPS animation data filesystem graphics image model3d scene thread
DEPS action animation data filesystem graphics image model3d scene thread
THIRDPARTY assimp
GROUP 3d)
eve_declare_module(NAME dialogue LAYER 6
@@ -26,7 +26,7 @@ eve_declare_module(NAME pixelworld_graphics LAYER 4 SCRIPT PixelWorldGraphics SL
GROUP 2d 3d web)
eve_declare_module(NAME camera LAYER 4 SCRIPT Camera SLOT camera
DEPS platform_event graphics scene
DEPS action platform_event graphics scene
GROUP minimal 2d 3d web)
eve_declare_module(NAME gpgpu LAYER 4 SCRIPT Gpgpu SLOT gpgpu
DEPS data filesystem graphics
@@ -47,6 +47,12 @@ eve_declare_module(NAME map LAYER 4 SCRIPT Map SLOT map
DEPS data filesystem graphics grid
THIRDPARTY poco
GROUP 2d 3d)
# Interactive 3D hex map: an editable pointy-top cell grid plus per-chunk mesh
# generation (ground fans, blend strips, terraces, cliffs, water, rivers, roads,
# city walls, decorations and the fog overlay).
eve_declare_module(NAME hexmap LAYER 4 SCRIPT HexMap SLOT hexmap
DEPS graphics
GROUP 3d)
eve_declare_module(NAME map_editing LAYER 4
DEPS editing
OPTIONAL_DEPS map
@@ -73,6 +79,15 @@ eve_declare_module(NAME physics_rope DIR physics/rope LIB EVPhysicsRope LAYER 5
SCRIPT Rope SLOT rope
DEPS physics schema
GROUP 3d web)
# Optional Action adapter for generation-safe 3D body-pair collision windows.
eve_declare_module(NAME physics_action DIR physics/action LAYER 5
DEPS action physics
GROUP 3d web)
# Optional map-backed steering provider. Keeping this bridge above both owners
# lets headless/minimal combat builds omit the rendering-heavy map closure.
eve_declare_module(NAME combat_navigation DIR combat/navigation LAYER 5
DEPS combat map
GROUP 2d 3d)
eve_declare_module(NAME pixelworld_physics LAYER 5 SCRIPT PixelWorldPhysics SLOT pixelworldPhysics
DEPS pixelworld physics
GROUP 2d)
+14 -1
View File
@@ -222,6 +222,12 @@ function(check_third_party_project name repo)
-DANDROID_NDK=${ANDROID_NDK}
-DCMAKE_SYSTEM_NAME=Android
)
# NDK 27 exposes arm_neon.h even for x86 targets, which makes this
# OpenAL Soft version mis-detect NEON and compile ARM intrinsics with
# an x86 compiler. Preserve NEON for the release arm64 build.
if(ANDROID_ABI MATCHES "^x86(_64)?$")
list(APPEND _eve_tp_cmake_args -DALSOFT_CPUEXT_NEON=OFF)
endif()
if(CMAKE_MAKE_PROGRAM)
list(APPEND _eve_tp_cmake_args -DCMAKE_MAKE_PROGRAM=${CMAKE_MAKE_PROGRAM})
endif()
@@ -409,7 +415,14 @@ function(check_third_party_project name repo)
)
endif()
target_include_directories(eve_engine_includes INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include)
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include
# SDL2 and FreeType install headers below their own include roots.
# These directories do not exist yet during a clean configure because
# the ExternalProject install happens in the later `deps` build. They
# must still be registered now; testing IS_DIRECTORY here makes clean
# iOS/simulator builds miss <SDL.h> or <ft2build.h>.
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include/SDL2
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include/freetype2)
eve_external_include(
${CMAKE_CURRENT_SOURCE_DIR}/build/${name}-binary/${TP_BUILD_PATH}/include)
target_link_directories(eve_engine_includes INTERFACE
+1 -1
View File
@@ -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)
+77
View File
@@ -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`:独立确定性算法实现。
+17
View File
@@ -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`,即可生成草地、沙地、石头、水体四材质预览;
默认直接读取随测试打包的三张图集,也可通过对应环境变量替换素材。
+13 -1
View File
@@ -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
+138
View File
@@ -0,0 +1,138 @@
# dnut 序列语言与解释器(模块 `dnut_interpreter`)
日期:2026-09-15
层:**L1**
源码:`src/modules/dnut_interpreter/`
清单:`cmake/module_manifest/core_foundation.cmake` 的 `dnut_interpreter` 声明
## 1. 这个模块拥有什么
`.dnut` 是引擎的**作者序列语言**。本模块是它的**唯一 owner**:
| 事实 | 唯一 owner | 说明 |
| --- | --- | --- |
| `.dnut` 词法 | `DnutLexer` | 所有方言共用;不变量是「同一文件只有一个词法器」 |
| 编译产物模型 | `SequenceAsset` / `SequenceNode` / `SequenceRoute` | 通用图模型,节点类型是**字符串**而非闭集枚举 |
| 步骤词表与字段表 | `StepKindRegistry` | descriptor 是字段的唯一真相源;编译器与作者 UI 都从它派生 |
| 跨帧执行游标 | `SequenceRuntime` | 调用栈、挂起/恢复、状态捕获、执行预算护栏 |
| `.dnut` → 资产的编译 | `DnutCompiler` | 只懂控制流关键字;步骤合法性交给注册表 |
| 条件求值的**契约** | `SequenceConditionOutcome` | 只定义「通过/不通过 + 稳定原因」;条件系统由消费者适配 |
**不拥有**(明确不在本模块):任何领域语义。`line`/`pool`/`story:move`/`skill` 都是
**注册项**,不是编译器或运行时里的 `switch` 分支。命令的实际执行、i18n、支付、
存档格式、表现后端都在各自的模块。
## 2. 对外六个面
| 面 | 内容 | 取证 |
| --- | --- | --- |
| 能力入(capability in) | 无。本模块不 `cap::query` 任何接口,也不调用 `getModInst`/`ModuleManager`。消费者直接链接并在自己线程上构造注册表与运行时。 | `grep -n "cap::\|getModInst\|requireModInst" src/modules/dnut_interpreter/*` → 0 命中 |
| 能力出(capability out) | 无 `cap::provide`。对外契约是**类型与函数**:`lexDnut` / `compileDnut` / `SequenceRuntime` / `StepKindRegistry`。 | 同上;`module_boundary_audit.py --capabilities` 中该模块无条目 |
| ECS + Link 数据 | 无。本模块不注册 ECS 组件、不注册 Link 种类、不创建实体。 | `grep -n "SystemContract\|LinkKind\|registerLinkKind"` → 0 命中 |
| 事件 | 唯一出口 `SequenceRuntime::setEventSink`;事件种类 `Started/NodeEntered/Blocked/Stepped/Resumed/Ended/Failed`,同步派发、无锁、不得重入。 | `SequenceRuntime.h` 的 `Event` / `EventSink` |
| 脚本绑定 | **无 SCRIPT 声明**。脚本面由消费者提供:`rpg` 绑定 `eve.rpg.newStorySession(...)` 等。 | manifest 中该模块无 `SCRIPT` |
| 工具协议 | 无 MCP/LSP/DAP 方法。 | — |
**跨模块包含**:公共头只包含 `common/Result.h`、`common/Value.h` 和本模块头。
不包含 `decision`、`dialogue`、`rpg` —— 条件求值通过 `std::function` 契约注入。
## 3. 裁剪声明
- **L1 编译期**:`dnut_interpreter` 的源文件不参与编译时,`dialogue`/`rpg` 的 TU 会
因找不到头而失败——它们是**硬依赖**(`DEPS`),不是可选依赖。
- **L2 链接期**:`rpg` 的 `RpgDialect.cpp` 调用 `compileDnut`、`SequenceRuntime::start`、
`StepKindRegistry::registerStep` 等非 inline、非虚函数,产生**未定义符号**,因此
关掉本模块后 `EVRPG` 无法链接。这是 `R-BOUND-2` 判据表「调 B 的非虚、非 inline
成员函数」一行。
- **L3/L4**:本模块没有运行时可选性——它要么被编入,要么消费者无法编译。因此
不声明 `GROUP`(与 `rpg` 一致,只随完整构建进入),也不需要 present/absent 双 profile。
- **符号引用证据**:`rpg/RpgDialect.cpp` 对 `eve::dnut::compileDnut(...)`、
`eve::dnut::SequenceRuntime::start(...)` 的调用即符号引用;`include` 图不作为判据
(`R-BOUND-1`)。
## 4. 语言表面
```
document := block*
block := 'story' IDENT modifier* '{' statement* '}'
| <其他方言的顶层块,直接跳过>
modifier := 'repeatable' | 'version' '=' INT
statement := step | ifStmt | choiceStmt | 'end'
step := IDENT (IDENT '=' value)*
ifStmt := 'if' condition block ['else' block]
choiceStmt := 'choice' '{' ('option' STRING ['when' condition] block)+ '}'
value := STRING | NUMBER | 'true' | 'false' | IDENT | '-' NUMBER
condition := 比较 / '&&' / '||' / '!' / '(' ')'
```
- `story` 之外的顶层块(`pool`、`conversation`)被**跳过**,因此一个文件可以同时
容纳其他方言而不被本编译器拒绝。
- 控制流关键字只有 `if` / `choice` / `call` / `wait` / `end`;它们分别下降到
`branch` / `choice` / `call` / `wait` / `end` 节点。**步骤类型不是关键字**:
`mark`、`skill`、`story:move` 一样由注册表声明。
- 条件编译为 `eve::Value` 对象:`{var,op,value}`、`{all:[…]}`、`{any:[…]}`、`{not:…}`,
`op ∈ eq|ne|gt|lt|ge|le`。**求值器由消费者注入**:语言核心不知道 `switch.x` 是什么。
### 4.1 编译错误恢复
单个语句失败时,编译器同步到下一个可能的语句起点继续,因此一次编译能报出多个
错误(例如一个块里两处拼写错误会得到 2 条诊断)。**含错误的文档不发布资产**:
`RpgStoryCatalogue::replaceFromDnutStrict` 在 `hasErrors()` 时保留上一份目录。
## 5. 步骤注册表契约
```cpp
StepKindDescriptor {
type, displayName, category,
shape(Instant | Await),
fields[{name, type(String|Number|Integer|Boolean|Any), required}],
validate(node) -> Result<void> // 跨字段的领域不变量
}
```
- `Instant`:必须绑定 handler;`validate` 会拒绝「instant 但没有 handler」的声明。
- `Await`:可以**不绑定 handler**,此时运行时挂起、宿主呈现后用
`SequenceRuntime::advance()` 确认;绑定了 handler 的 `Await` 可以返回 `Blocked`
并在之后用 `resumeStep(value)` 继续。
- 字段校验是**严格**的:未声明的字段被拒绝。这是刻意的——拼错的字段名不会静默
变成一个永远为空的属性。
- `validate` 是跨字段规则(「`learn` 与 `forget` 恰有其一」)的落点,因此这类
错误在**编译内容时**报出,而不是等玩家走到那一步。
### 5.1 `StepContext::host`
语言核心不理解消费者的领域对象。消费者在运行时上装一个不透明指针
(`setHostContext`),核心原样转发给 handler。`rpg` 用它传 `RpgStoryBinding*`。
这保持了「核心零领域依赖」,代价是一个需要文档说明的 `void*`。
## 6. 所有权、生命周期与线程
- `SequenceAsset` / `StepKindRegistry` 归调用者所有。`SequenceRuntime` 持有
**借用**指针,调用者必须保证它们在运行时存活期内有效。
- `RpgStorySession` 在 `begin` 时**快照整份目录**,因此热替换不会让活动会话悬垂。
- `captureState` 存的是**稳定引用**(`asset id` + `version` + `node id` + bindings +
locals),不是指针。`restoreState` 通过资产解析器重新解析,并在 `version` 不匹配时
明确失败。
- 所有方法都是**owner 线程专属**,不做同步;事件回调同步派发,且不得重入运行时。
- 执行预算 `kExecutionBudget = 10000` 是内容写出死循环时的唯一护栏:超预算失败而不是挂死。
## 7. 已知债务(显式登记)
| 债务 | owner | 原因 | 移除条件 |
| --- | --- | --- | --- |
| `dialogue` 的 `pool` / `conversation` 两套前端尚未迁到本模块 | dialogue | 迁移触及 15 个文件 + 8 个测试文件,与「新增 RPG 方言能力」混在一次交付里失败模式不可分离 | 设计文档 `docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md` §8 阶段 1 的剩余部分完成 |
| 顶层块切分(`DnutBlockScanner`)尚未抽成显式类型,目前是编译器内的跳过逻辑 | dnut_interpreter | 只有 `story` 一种块需要真正解析,抽象尚无第二个消费者 | dialogue 迁移开始时随迁移落地 |
| `SequenceDocument`(作者字段反射)尚未落地 | dnut_interpreter | 当前无作者 UI 消费者;字段表已由 `payloadSchema` 唯一拥有,反射可按需派生 | 编辑器接入 `.dnut` 作者视图时 |
| `rpg::StoryEvent`(JSON 闭集)与 `.dnut` 故事并存 | rpg | 既有 JSON 路径有 4 个测试文件与线格式兼容要求;本次交付只新增能力,不做格式替换 | 设计文档 §8 阶段 2 完成时 |
债务没有第二真相源:`.dnut` 的语法、模型与游标只有本模块一份实现;
`StoryEvent` 是**另一种内容格式**,不是同一事实的第二份表示。
## 8. 验收
```sh
python3 scripts/check_module_manifest.py
python3 scripts/module_depgraph.py --check --check-layers
build/win32-debug/test/unit_test.exe "dnut.*"
build/win32-debug/test/unit_test.exe "rpg.dnut.*"
```
+35
View File
@@ -116,3 +116,38 @@ procedural PBR surfaces, perturbs the boundary with world-space FBM and performs
GGX metallic-roughness lighting evaluation. This gives stable, asset-free
transitions and validates the end-to-end path, but it supports only two layers per
vertex and should not become the permanent terrain data format.
## Update: the `hexmap` module
The hex map was subsequently ported from the Catlike Coding reference project as a
dedicated module rather than another procgen recipe, because an *editable* map needs
authoritative cell data and incremental mesh updates that a one-shot generator
cannot provide. `docs/usr/modules/hexmap.md` is the module contract;
`examples/hex-terrain-3d` is the interactive editor over it. It deliberately lives
in its own directory so that `examples/hex-terrain` — the `mesh.hexterrain` proof
described above — keeps shipping unchanged.
What that closes from the gap list above:
- Gaps 1 and 5 (no cell data, no chunking, no picking): `hexmap::HexMap` owns the
cell grid (elevation, water, terrain, feature levels, river/road connections),
partitions it into 5x5 chunks with dirty tracking, answers neighbour/edge
queries, and converts a world-space ray back to a cell.
- Gap 5's mesh side: `buildChunkSurfaceMesh` generates the terrain, water, river
and road streams per chunk, and `HexMapModule::rebuildChunk` updates only the
dirty surfaces in place.
- Gap 7's "one baked mesh" half: the walkable terrain is now the chunked,
independent surface the audit asked for.
What still stands:
- Gaps 2-4 (bindable texture arrays, independent roughness/metallic/AO maps, a
CPU-to-GPU `PbrTextureSet`) are unchanged. The example therefore still shades the
terrain procedurally from a packed vertex encoding, now three layers instead of
two; `HexMapMesh.h` documents that as a compatibility bridge.
- Gap 6 (production hydrology: basin filling, flow accumulation, confluences) is
unchanged. The module enforces the reference's downhill/lake-outflow rule and
draws channels, but does not solve drainage.
- `mesh.hexterrain` and its generator are untouched and still ship the original
proof; the module is additive.
@@ -0,0 +1,181 @@
# 实施计划:dnut 序列语言核心(L1)与 RPG 方言
> 上位设计:`docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md`
> 状态:**已实施**(§1.1 语言核心、§1.2 RPG 词表、§1.3 测试与文档全部落地;
> §5 角色移动与 §6 动画播放按增量补齐;`SequenceDocument` 与 dialogue 迁移按 §4 登记为后续增量)
> 目标(用户原话):给 dnut 语言中的 rpg 方言添加人物的移动、动画播放、对象的选取、
> 技能的获取、属性的修改、物品、装备获取等等功能——**完整实现**。
## 0. 与设计文档的偏差声明
设计的「阶段 1」把 dialogue 的 conversation 方言整体迁入 L1。本计划**先交付可运行的
RPG 方言与其 L1 语言核心**,把 dialogue 迁移留作后续增量,理由:
1. 用户目标是**能力**(RPG 方言可表达并可执行),不是模块搬迁本身;
2. dialogue 迁移会让一个 PR 触及 15 个文件 + 8 个测试文件,把「新能力」与
「纯搬迁」混在一次交付里,失败模式不可分离;
3. 语言核心(词法/资产模型/步骤注册表/运行时)一经建立,dialogue 迁移是**纯消费者改造**,
不再需要新机制。
因此顺序为:**语言核心(L1)→ RPG 方言语汇 → RPG 接入与绑定 → dialogue 迁移(后续)**。
`docs/dev/superpowers/specs/2026-09-15-dnut-interpreter-l1-design.md` §8 的阶段划分
按此顺序重读:本计划覆盖其阶段 1 的「新模块 + 语言」与阶段 2、3 的 RPG 部分。
## 1. 交付物清单
### 1.1 新模块 `src/modules/dnut_interpreter/`(L1)
| 文件 | 职责 |
| --- | --- |
| `DnutDiagnostic.h` | 与领域无关的诊断:`Severity`/`path`/`line`/`column`/`message` |
| `DnutLexer.h/.cpp` | 统一词法器(今天 `DnutParser.cpp:101-217` 的升格),带行/列 |
| `DnutBlockScanner.h/.cpp` | 顶层块切分(`story` / `pool` / `conversation`),供多方言共用 |
| `SequenceAsset.h/.cpp` | 资产模型 `SequenceAsset`/`SequenceNode`/`SequenceRoute` + 图校验 |
| `StepKindRegistry.h/.cpp` | 步骤类型注册表:descriptor + schema + validate + handler + dispatch |
| `DnutCompiler.h/.cpp` | 注册表驱动的 `.dnut` → `SequenceAsset` 编译器(控制流核心 + 通用步骤) |
| `SequenceRuntime.h/.cpp` | 跨帧游标运行时:分支/调用/等待/命令派发/挂起恢复/状态捕获/预算护栏 |
| `SequenceDocument.h/.cpp` | 作者文档反射(字段表由 `payloadSchema` 派生) |
### 1.2 `src/modules/rpg/` 增补(L1 消费者)
| 文件 | 职责 |
| --- | --- |
| `RpgDialect.h/.cpp` | 注册 RPG 步骤词表 + 领域 handler + `.dnut` story 装载门面 |
| `RPG.cpp` | 脚本绑定:装载、执行、观测 |
| `StoryEvent.h/.cpp` | 保持 v1 线格式与游标语义;新增 `.dnut` 内容源(不破坏既有 JSON 路径) |
### 1.3 测试与文档
- `test/dnut_interpreter_language.cpp`:词法、块切分、编译、诊断、注册表校验。
- `test/dnut_interpreter_runtime.cpp`:控制流、挂起/恢复、捕获重建、预算护栏。
- `test/rpg_dialect.cpp`:七类 RPG 步骤的语义与失败路径。
- `docs/dev/dnut序列语言与解释器.md`:模块 owner 文档(六面边界 + 裁剪声明)。
- `docs/usr/modules/rpg.md`:RPG 方言语法与用法。
## 2. RPG 方言(语言表面)
```
story <id> [repeatable] {
move actor=<id> x=<n> y=<n> [duration=<n>]
animation target=<id> clip=<uri> [loop] [hold]
select object=<id> [prompt=<text>]
skill actor=<id> learn=<id> # forget=<id> 亦为同一步骤的模式
attribute actor=<id> name=<id> op=set|add value=<n>
item add=<id> [count=<n>] | remove=<id> [count=<n>]
equipment actor=<id> equip=<id> | unequip=<slot>
message text=<text>
dialogue id=<conversationId>
wait duration=<n>
camera x=<n> y=<n> [duration=<n>]
if <condition> { ... } [else { ... }]
choice { option "label" [when <condition>] -> <target> }
call <storyId>
end
}
```
- 步骤类型**不是**编译器里的 `switch`:`move`/`animation`/... 全部由 `rpg` 注册,
编译器只按注册表校验字段。
- `if`/`choice`/`call`/`end`/`wait` 是语言核心的控制流,编译为 `branch`/`choice`/`call`/
`end`/`wait` 节点。
- 条件沿用 `decision::Condition` 的 `eve::Value` 形态(`var`/`op`/`value`、`all`/`any`/`not`)。
## 3. 验收
```sh
python3 scripts/check_module_manifest.py
python3 scripts/module_depgraph.py --check --check-layers
make build/win32-debug
build/win32-debug/test/unit_test.exe "dnut_interpreter.*" # 或用 CTest 过滤
build/win32-debug/test/unit_test.exe "rpg_dialect.*"
```
## 4. 未纳入本计划的既有债务(显式登记)
- dialogue 的 conversation/pool 方言尚未迁到 L1 语言核心(设计阶段 1 剩余部分)。
迁移前,`.dnut` 仍有两套前端;本计划**不新增第三套**——RPG 方言直接消费 L1 词法器与
块切分器,dialogue 的两套方言保持原状,待迁移时统一。
## 5. 增量:角色移动(对设计 §8 阶段 3 的有意偏离)
设计文档 `2026-09-15-dnut-interpreter-l1-design.md` §8 阶段 3 把移动描述为
「**基于既有 `map::Pathfinder` + 一个按格移动控制器**」。本次交付**不那样做**,
改为在 `rpg` 侧提供一个宿主注入的移动端口:
```cpp
struct RpgStoryBinding {
…
StoryMoveHandler moveActor; // Result<StoryMoveStatus>(const StoryMoveRequest&)
};
```
偏离理由——设计里那条路会把方言**焊死在一种玩法上**:
1. `map::Pathfinder` 是**格子地图**的寻路器。把 `move` 的实现接到它上面,等于规定
`actor` 必须是地图上的实体、`x`/`y` 必须是格坐标、移动必须存在一条格子路径。
战棋、连续 3D、横版与轨道序列都会被迫先造一张假地图。
2. `rpg` 是 **L1**,`map` 更高层;直接依赖会新增一条上行依赖,违反
`scripts/module_depgraph.py --check-layers` 的判据。
3. `move` 的真值只有「谁、去哪里、多久」——**怎么走**是玩法的自由度,不是语言事实。
端口形态保留了两个方向的能力:
- **便捷**:装一个 handler,`move` 步骤自动派发,宿主不再需要在循环里写
`if (kind == "move")`;返回 `Arrived` 时(瞬移、已在目标点)剧情不挂起。
- **灵活**:不装 handler 时行为**逐字不变**——仍是宿主呈现步骤,脚本读 payload 后
`advance()`。非队伍主体(地图物件、相机、编队)也能移动,`actor` 投影为 null
而步骤照常执行。
取舍明说:**本次不提供按格移动控制器**,因此「内容作者想要一条自动绕障的路径」这件事
仍然没有开箱实现——那是某个具体玩法适配器的职责,应该建在 `map` 之上而不是
`dnut_interpreter` 或 `rpg` 里。`x`/`y` 的单位与语义由宿主定义并在其文档中写明。
证据:
- `src/modules/rpg/RpgDialect.h`:`StoryMoveStatus` / `StoryMoveRequest` / `StoryMoveHandler`。
- `src/modules/rpg/RpgDialect.cpp`:`performMove` + `bind("move", performMove)`;无控制器时
返回 `Blocked`,与运行时的宿主呈现挂起**观测等价**。
- `test/rpg_dialect.cpp`:`FakeMoveWorld` 参考端口 + 六条语义/失败/模式无关用例。
- `test/rpg_dnut_script.cpp`:Squirrel 端到端驱动 `move` 并核对效果。
## 6. 增量:动画播放(与 §5 同构)
§2 的词表早就声明了 `animation`,但只到「编译能过、运行时把它当宿主呈现步骤挂起」为止:
没有 handler、没有端口、没有测试、没有样例。本次按 §5 已定的同一形状补齐它,理由是
这两步的**真值形状完全一样**——「谁、播什么、播完没有」,而「怎么播」同样是玩法的自由度。
端口形态:
```cpp
enum class StoryAnimationStatus { Finished, Playing, Unavailable };
struct StoryAnimationRequest { targetId, actor, clip, loop, hold };
struct RpgStoryBinding {
…
StoryAnimationHandler playAnimation; // Result<StoryAnimationStatus>(const StoryAnimationRequest&)
};
```
与 §5 一致的取舍:
- **便捷**:装一个 handler,`animation` 步骤自动派发,宿主不再需要在循环里写
`if (kind == "animation")`;返回 `Finished` 时(瞬发、零长 clip)剧情不挂起。
- **灵活**:不装 handler 时行为**逐字不变**——仍是宿主呈现步骤,脚本读 payload 后
`advance()`。特效挂点、UI 元素等非队伍主体也能作为 `target`,`actor` 投影为 null
而步骤照常执行。
- **不绑死**:`clip` 对引擎是不透明字符串,不解析、不加载。接上某个动画栈(骨骼、
Sprite、Spine、montage)是宿主适配器的职责,不是 `dnut_interpreter` 或 `rpg` 的。
字段命名修正:§2 原先写 `[await]`,实现与 `docs/usr/modules/rpg.md` 都是 `[hold]`。
**以 `hold` 为准**——`await` 与步骤形状 `StepShape::Await`(该步骤本来就挂起)语义重复,
而 `hold`(播完停在最后一帧)才是宿主真正需要的、引擎无法自行推断的信息。本次同步把
计划书的 `[await]` 改成 `[hold]`。`loop` / `hold` 均按作者原样透传,引擎不解释。
证据:
- `src/modules/rpg/RpgDialect.h`:`StoryAnimationStatus` / `StoryAnimationRequest` / `StoryAnimationHandler`。
- `src/modules/rpg/RpgDialect.cpp`:`performAnimation` + `bind("animation", performAnimation)`;无控制器时
返回 `Blocked`,与运行时的宿主呈现挂起**观测等价**。
- `test/rpg_dialect.cpp`:`FakeAnimStage` 参考端口 + 七条语义/失败/模式无关用例。
- `test/rpg_dnut_script.cpp`:Squirrel 端到端驱动 `animation` 并核对效果。
@@ -0,0 +1,498 @@
# dnut_interpreter 模块设计(.dnut 序列语言与解释器下沉到 L1)
> 状态:**设计定案,未实施**;日期:2026-09-15
>
> **修订 v2**:归属从「塞进 `action` 模块」改为**新建 L1 模块 `dnut_interpreter`**,
> 且 `.dnut` 前端(词法/语法/编译/lint)随之下沉。v1 把序列运行时放进 `action`,
> 理由是成本(`action` 已是 L1、已永远编入、`rpg` 已依赖它、已有同族组件),
> 但 `action` 的契约是**单个动作的执行生命周期**(`src/modules/action/Action.h:5-13`、
> `ActionPhase` 于 `Action.h:38`),与「场景级序列解释器」在**粒度、控制方向、脚本面**
> 三处都不匹配;且 `action` 是 `LIB EVAction`、manifest 无 SCRIPT
> (`cmake/module_manifest/core_foundation.cmake:145-146`),装不下「脚本可扩展的语言」。
>
> **目标**:让引擎拥有**唯一的作者序列语言** `.dnut` 及其**唯一的解释器 owner**——
> 语言前端、步骤类型注册表、跨帧游标与分支/调用/等待/命令派发全部归 L1;
> `dialogue`(L6)与 `rpg`(L1)降为消费者,各自只注册自己的**块类型**与**步骤类型**;
> 表现层(sprite / Spine / 3D skeletal / Live2D / 地图 / 相机 / 音频)通过注册命令
> handler 接入,不进入语言与运行时。
>
> **涉及改动**:新增 `src/modules/dnut_interpreter/`;
> `cmake/module_manifest/core_foundation.cmake` 新增一条 L1 声明;
> `src/modules/dialogue/` 大面积迁移(§7 逐文件清单);
> `cmake/module_manifest/orchestration.cmake:238` 给 `dialogue` 加依赖;
> `src/modules/rpg/StoryEvent.{h,cpp}` 接入;8 个测试文件、3 份模块文档。
## 1. 背景与现状
### 1.1 `.dnut` 今天是什么(2026-09-15 代码核对)
`.dnut` 已经在,但**一个扩展名下有两套互不相识的前端和两个入口**:
| 方言 | 解析器 | 产物 | 入口 | 依赖 |
|---|---|---|---|---|
| `pool { when … speaker: "…" }` | `dialogue/DnutParser.cpp`(真词法分析器 + 递归下降,`tokenize()` 于 `DnutParser.cpp:101-217`) | `DataValue` 根表(`DataValue` 是 `eve::Value` 的别名,`Dialogue.h:34`) | `Dialogue::loadPoolsFromDnut` / `loadPoolsFromDnutFile`(`Dialogue.h:138-140`) | `dialogue/Dialogue.h`(L6) |
| `conversation … node … when … -> …` | `dialogue/ConversationCompiler.cpp:85-177`(逐行 `words()` 切分,无词法器) | `ConversationAsset`(`Conversation.h:40-75`) | `DialogueFlow::loadFromDnut` / `loadFromDnutFile`(`DialogueFlow.h:100-102`) | `dialogue/Conversation.h`(L6) |
- **「两方言可共存于同一文件」只有一侧成立。** `docs/usr/modules/dialogue.md:169`
如此声明,`ConversationCompiler.cpp:129-131` 也确实显式跳过 pool 语法;但
`parseDnut` 遇到顶层 `conversation` 块会在 `DnutParser.cpp:414-415`
(`expectIdent("pool")`)直接失败。今天 `examples/dialogue/pools.dnut` 与
`examples/rpg-classic/data/village-dialogue.dnut` 是两份不同方言的文件,
靠两个不同入口分别加载。
- **conversation 方言的词表是编译期闭集。** 节点种类 `line|branch|choice|call|command|wait|end`
硬编码在 `ConversationCompiler.cpp:61-72`;属性名硬编码在
`ConversationCompiler.cpp:147-155`(`next/speaker/text/pool/i18n/voice/target/return/result`)。
没有任何区块或步骤的注册入口。
- **作者侧已经有一份手写反射。** `ConversationAuthoring.h` 的
`ConversationDocument::getFieldCount/getFieldName/getFieldKind/getField/setField`
按节点种类列出可编辑字段——这是「步骤类型描述符」的事实前身,但它是第二份真相源,
与 `ConversationCompiler.cpp:147-155` 的属性表各自维护。
- **控制流与领域焊在同一个类型里。** `ConversationAsset::Node` 同时装
`Branch/Choice/Call/Command/Wait/End`(控制流)与 `speaker/text/pool/i18nKey/voice/expression`
(对话领域),以及 `PaymentSpec`/`StateMutation`/`CommandRequestKind`(对话事务语义)。
### 1.2 执行与消费现状
- 执行器 `ConversationRunner`(`Conversation.h:78-228`,实现于 `Conversation.cpp`)
已经有完整的跨帧游标:`runUntilBlocked`(`Conversation.cpp:167-272`,含
`Conversation.cpp:168` 的 10000 步预算护栏)、`advance`(`:398`)、`select`(`:407`)、
`selectRouteForTransaction`(`:424`)、`resumeCommand` 双重载(`:373`、`:394`)、
`captureState`/`restoreState`(`:296`/`:310`,帧内用 `captureFrame`,`:283-292`)。
- 消费面**全部在 dialogue 模块内**:`ConversationImporter`(Yarn/Twee)、
`ConversationToolchain`(跨资产 lint 与重命名)、`ConversationAuthoring`
(`ConversationDocument`)、`ConversationPersistence`(存档 JSON + 版本迁移)、
`ConversationLocalization`、`editing/DialogueGraph`、`editor/EditorDialoguePreview`、
`DialogueFlow`。模块外只有测试与 `examples/`(`test/dialogue_*.cpp` 共 17 个,
其中 8 个直接包含迁移中的头文件;另有 `test/rpg_classic*.cpp`、
`examples/rpg-classic/main.nut`);`src/` 下唯一的跨模块引用是
`src/modules/i18n/editing/LocalizationRuntime.cpp` 引用 `DialogueVoice.h`(与本次无关)。
### 1.3 通用机制其实已经齐了,缺的是一个 owner
| 已有能力 | 归属 | 层 | 提供什么 |
|---|---|---|---|
| 具名命令描述符 + handler 路由 + 校验 | `action::ActionNotifyRegistry` | L1 | `type + displayName + category + shape(Instant/State) + requiredPayloadFields`;`registerDescriptor`/`registerHandler`/`validate`/`dispatch`;`withBuiltins()` 已含 `presentation:vfx`/`presentation:audio`/`presentation:camera`(`ActionNotifyRegistry.cpp:21-34`) |
| 表现引用不绑后端 | `action::ActionTimeline` | L1 | 8 条语义轨道、Notify/StateEnter/StateExit、确定性 `sample(previous,current)`;"resources are referenced by URI/type payloads and resolved by downstream adapters"(`ActionTimeline.h:99-101`) |
| 分支条件 AST | `decision::Condition` | L0 | `All/Any/Not/Compare/HasTag/HasAttribute/HasResource/StateEquals/AuthorityCheck/PolicyCall`(`Condition.h:20`) |
| 脚本可注册策略 | `policyregistry::PolicyRegistry` | L0 | `ImplementationKind { Builtin, Script, Batch }` + generation 失效 |
| 宿主中立命令/观测 | `common::IGameplayControlProvider` | L0 | action descriptor 带 `parameterSchema`、command 带 `expectedRevision`、Capability 多 provider |
| 热重载 | `filesystem::HotReload` + `Runtime::reload(ScriptId)` + 目录原子替换范式 | — | `HotReload.h:19-33`、`Runtime.h:357`、`StoryEvent.h:62` |
### 1.4 rpg 侧只有平铺数据,且够不着 dialogue
- `enum class StoryEventStepKind { Dialogue, Message, Wait, Move, Camera }`
(`StoryEvent.h:15`)是编译期闭集,1–128 步线性序列,固定字段
`{reference, actorId, x, y, duration}`(`StoryEvent.h:18-25`);`StoryEvent.cpp:33-58`
的 `parseKind`/`kindName` 是唯一的种类真相源。没有分支、循环、标签、公共事件、
脚本命令、开关/变量指令、移动路线、动画/音频/画面特效指令,也没有命令注册入口。
- 执行逻辑在宿主脚本里:`examples/rpg-classic/main.nut:714-753` 用
`getStepKind()` 比较 + if/else 链逐条驱动。
- **决定性约束:`rpg` 够不着 `dialogue`。** `rpg` 是 **LAYER 1**
(`core_foundation.cmake:102-103`),`dialogue` 是 **LAYER 6**
(`orchestration.cmake:238-241`)。L1 依赖 L6 会被
`scripts/module_depgraph.py --check-layers` 判失败。这就是 `StoryEvent` 为何是闭集枚举。
## 2. 问题定义
缺的不是「语言基础设施」,而是三件绑定在一起的事:
1. **一个 owner 负责「作者序列语言 + 跨帧游标 + 场景级控制流」。**
`ActionTimeline` 是**单个动作内部**的采样器(无跨帧游标、无分支、无挂起);
`common::IGameplayControlProvider` 是**单条命令**的提交/观测协议(无序列);
`TransitionRunner` 有完整控制流但在 L6 且与对话领域焊死。
2. **`.dnut` 前端的分裂要收口。** 同一扩展名两套词法、两个入口、两种产物,
且「共处一文件」只有半侧成立;新增步骤类型必须改 `ConversationCompiler.cpp:61-72`
的闭集。没有统一前端,就不存在「可扩展的语言」。
3. **`.dnut` 必须对 rpg 可用。** rpg 在 L1,前端与运行时必须在 L1 或更低,
否则 rpg 只能继续写第二套闭集枚举。
因此本次工作有三半,缺一不可:
1. **新建**:L1 模块 `dnut_interpreter`,拥有 `.dnut` 前端 + 资产模型 + 步骤类型注册表 + 运行时。
2. **迁移**:`dialogue` 的 conversation 方言与执行器下沉/改写为它的消费者,
领域语义(payment / stateMutations / i18n / voice / 台词池 / UX)留在 dialogue。
3. **接入**:`rpg` 剧情事件改为该运行时上的注册步骤类型;两个消费者各自只注册词表。
## 3. 设计原则
1. **一个事实一个 owner。** `.dnut` 的语法、资产模型、序列游标、命令派发归
`dnut_interpreter`;条件归 `decision::Condition`;命令词表归
`action::ActionNotifyRegistry`;持久游标/完成事实/开关变量归 `rpg::GameState`
(`StoryEvent.h:61-65` 已确立该契约);命令的实际执行归各适配器。
2. **语言不懂领域。** `line` / `pool` / `story:move` / `story:animation` 都是**注册项**,
不是编译器或运行时里的 `switch` 分支。`dialogue` 注册 `line` 步骤与 `pool` 块;
`rpg` 注册 `story:*` 步骤。
3. **命令只发意图,不绑表现后端。** payload 一律 URI/type 字符串,沿用
`ActionTimeline.h:99-101` 的既定原则。sprite / Spine / 3D skeletal / Live2D
的差别落在 handler 实现上,换后端不改脚本。
4. **运行时不断言品类。** 它只推进游标、求条件、派发步骤;宿主边界靠
`GameState` 游标 + 适配器注册,因此俯视 tile RPG / HD2D / 3D 动作共用同一份脚本。
5. **保语义搬迁。** 挂起/恢复、事务化选择、状态捕获三处逐字保语义(§5.5),
不顺手重构;`.dnut` 既有源码的**接受集不变**(§5.9)。
6. **可重载。** 沿用已证范式:严格校验 → 原子替换目录 → 活动会话持定义副本 →
事件内存档后重放未确认步骤。
## 4. 总体架构
```text
L1 dnut_interpreter(新模块)
语言前端 DnutLexer 统一的 .dnut 词法器(今天的 tokenizer 升格)
DnutBlockScanner 顶层块切分:pool{…} / conversation…endconversation
DnutCompiler conversation 块 → SequenceAsset,按注册表校验步骤类型
DnutLint 可达性 / 重复 id / 跨资产引用 / 迁移校验
资产模型 SequenceAsset / SequenceNode{id,type,next,payload,routes}
SequenceRoute{label,condition,target,payload}
SequenceDocument 可编辑文档(取代手写反射的 ConversationDocument)
步骤词表 StepKindRegistry descriptor + handler + payloadSchema + validate
BlockKindRegistry (后续增量)块类型的注册入口
运行时 SequenceRuntime 跨帧游标 + 调用栈 + 挂起/恢复 + 状态捕获 + 预算护栏
SequenceCommandRequest / Response / Handler
持久化 SequenceSaveMigrations 资产 id + version + node 迁移(与语言无关)
L0 decision::Condition 条件求值(不变)
policyregistry 脚本策略(不变)
common::Value/StateValue 值类型(不变)
L6 dialogue(消费者)
注册:步骤 `line`、块 `pool`
保留:台词池/Dialogue、PaymentSpec、StateMutation、i18n、voice、UX、文本渲染、
Yarn/Twee 导入、存档 JSON 编解码
删除:ConversationAsset / ConversationRunner(被 L1 资产模型与运行时取代)
L1 rpg(消费者)
注册:`story:dialogue` / `story:message` / `story:wait` / `story:move` / `story:camera`
(后续)`story:animation` / `story:expression` / `story:wait-for-motion`
保留:GameState(持久游标与完成事实的唯一权威)、StoryEventCatalogue 的内容目录契约
适配器(各自层)注册命令 handler
avatar / animation / map / audio / ui / camera …
```
## 5. 详细设计
### 5.1 归属判据:逐文件/逐类型的迁移表
`dialogue` 现有 33 个文件按归属分类(依据:`Conversation*`/`Dnut*` 的 `#include` 图与类型用法):
| 今天的文件/类型 | 归属 | 理由 |
|---|---|---|
| `DnutParser` 的 `tokenize()`(`DnutParser.cpp:101-217`) | **L1** | `.dnut` 词法,两种方言共用 |
| `ConversationCompiler`(语句解析、`nodeKind`、`attributes`、`lintConversations`) | **L1** | conversation 方言的语法与图校验;节点种类改由注册表解析 |
| `ConversationAsset` / `Node` / `ConversationRoute`(`Conversation.h:22-75`) | **L1** | 通用资产模型;领域字段改入 `payload` |
| `ConversationAsset::validate`(`Conversation.cpp:23-65`) | **L1 + dialogue** | 通用图校验(id/入口/引用)归 L1;`payment`/`stateMutations` 的「仅 command/choice 可用」规则归 dialogue 的步骤校验 |
| `ConversationRunner`(`Conversation.h:78-228`、`Conversation.cpp:67-459`) | **L1** | 纯控制流 + 游标(§5.5 三处逐字保语义) |
| `ConversationRunner::CommandResult/Event/CommandHandler` | **L1** | 通用命令与事件类型 |
| `CommandRequest`/`CommandResponse`/`CommandRequestKind`(`DialogueState.h:23-58`) | **L1(通用部分)+ dialogue(领域字段)** | `name/arguments/bindings/locals` 通用;`kind/payment/stateMutations` 是对话事务语义,留 dialogue |
| `toCanonicalValue`/`toDialogueStateValue`(`DialogueState.h:65,72`) | **dialogue** | 对话侧 legacy 值转换,L1 不引用 |
| `ConversationPersistence` 的 `ConversationSaveMigrations` | **L1** | 只依赖「资产 id + version + 节点 id 映射」,与领域无关 |
| `ConversationPersistence` 的 StateValue↔JSON | **dialogue** | 对话存档格式 |
| `ConversationToolchain`(跨资产 lint、`renameConversationAsset/Node`) | **L1** | 全是对资产图的操作 |
| `ConversationAuthoring`(`ConversationDocument`) | **L1** | 手写字段反射正是步骤描述符的 `payloadSchema`;保留即第二真相源 |
| `ConversationImporter`(Yarn / Twee) | **dialogue** | 外部对话格式到 `line` 步骤的映射是对话领域知识;产物是 L1 资产 |
| `ConversationLocalization` / `ConversationText` / `DialoguePayment` / `DialogueState` / `Dialogue.{h,cpp}` / `DialogueUX` / `DialogueVoice` / `editing/DialogueGraph` / `editor/EditorDialoguePreview` | **dialogue** | 领域载荷、本地化、表现与作者 UI |
| `DialogueFlow`(`DialogueFlow.h:33-232`) | **dialogue** | 脚本门面;内部改为持有 L1 运行时并注册 `line` 步骤 |
### 5.2 资产模型
```text
SequenceAsset {
id, version, entry, parameters[],
nodes[]: SequenceNode { id, type, next, payload, routes[] }
}
SequenceRoute { label, condition, target, payload }
```
- `type` 是**字符串**步骤类型名(`line` / `branch` / `choice` / `call` / `command` / `wait` /
`end` / `story:move` / …),不再是闭集枚举。
- `payload` 是 `eve::Value::Object`:步骤类型自己的字段,由该类型的 descriptor 校验与解释。
`line` 的 `speaker/text/pool/i18n/voice/expression`、`command` 的 `arguments/payment/stateMutations`、
`choice` 的每条路由 `payment/stateMutations` 都进 payload。
- `condition` 沿用 `eve::Value`(今天 `ConversationRoute::condition` 即如此,`Conversation.h:25`)。
**为什么不让 `ConversationAsset` 留在 dialogue 与 L1 资产并存。** 并存就是同一份内容的两份
表示,正是本次要消除的「第二套控制流」;而且运行时的 `dispatch` 必须把 payload 交给**领域
注册的 handler**,L1 不可能知道 dialogue 的强类型结构,所以运行时一侧本来就必须接受弱类型
payload。既然运行时已经弱类型,资产再强类型只会多一次同步。dialogue 侧改为**无状态的
payload 编解码**(`decodeLinePayload(node)` 返回强类型视图),不持有第二份资产。
**拒绝的替代方案**:L1 定义资产**接口**(`ISequenceAsset` + 视图),dialogue 保留具体强类型资产
实现它。好处是 dialogue 侧改动小;坏处是每个领域字段都要走一次虚函数取值,且「步骤类型
的字段表」这个通用事实仍留在 dialogue,`ConversationDocument` 的手写反射也留着。故不采用。
### 5.3 步骤类型与块类型注册表
复用 `ActionNotifyRegistry` 已证明的形态(`ActionNotifyRegistry.h:19-71`),不新造机制:
```text
StepKindDescriptor {
type, displayName, category,
shape(Instant | State | Await), // Await = 阻塞直到宿主 ack
payloadSchema, // 字段表:同时供编译器严格校验与作者 UI 反射
validate(payload) -> Result<void> // 领域不变量(如 payment 只允许出现在 command/choice)
}
registerStep(type, descriptor, handler) / unregisterStep(type)
validate(node) / dispatch(node, context)
```
- `conversation` 块本身由 L1 编译;`pool` 块在阶段 1 仍由 dialogue 的池解析器消费,
但**共用 L1 的词法器与顶层块切分**(`DnutBlockScanner`)。块类型的完整注册入口
(`BlockKindRegistry`)列为后续增量,见 §11。
- `dialogue` 注册 `line`(Await)与 `choice` 的领域校验;`rpg` 注册 `story:*`(§4)。
- 适配器注册命令:`avatar`/`animation`/`map`/`audio`/`ui` 各自实现
`presentation:*` / `movement:*` 的 handler。
- `ConversationDocument` 的 `getFieldName/getFieldKind` 反射(`ConversationAuthoring.h`)
由 `payloadSchema` 派生,消除第二份字段表。
### 5.4 控制流集合
**本设计交付**(与今天 `ConversationRunner` 等价,纯搬迁):
```text
Branch 取第一条条件通过的 route,否则 next (Conversation.cpp:184-189)
Call 压栈 + 切入被调用资产 + bindings mergeDefaults + 结束帧时弹栈恢复(:204-225)
Command 派发具名命令;Blocked → blocked_ + waitingCommand_;Failed → 失败(:226-268)
Wait 阻塞,等宿主 advance (:181-183)
End 栈空则 Ended + stop,否则弹栈回到 returnNode (:190-203)
```
**后续增量**(不在本设计交付,但 owner 已就位):
`Loop / BreakLoop / Label + Jump / CallCommonEvent / Parallel`。
### 5.5 必须逐字保语义的四处
1. **双重重载**:`resumeCommand(StateValue)`(`Conversation.cpp:373`)与
`resumeCommand(eve::Value)`(`Conversation.cpp:394`)。L1 运行时**两个都保留**,
不做「只留 canonical 版本 + dialogue 加转换壳」的改写。理由:`common/StateValue.h`
是 L0 且 L1 已有直接使用先例(`src/modules/rpg/RpgState.h:3`);保留双载 = 零转换
= 零行为变化,引入转换壳反而制造一条需要单独证明等价的新路径。
`toCanonicalValue` / `toDialogueStateValue`(`DialogueState.h:65,72`)留在 dialogue,不被 L1 引用。
2. **事务化选择**:`selectRouteForTransaction`(`Conversation.cpp:424-459`)与
`DialogueFlow.cpp` 的支付/状态事务包装。运行时的 `select` 必须保持
「选中即提交、失败保持游标」的现状语义(`:445-455` 的 capture → enter → restore 序列),
事务包装留在 dialogue。
3. **调用栈重建**:`captureState` 存的是 `{asset id, asset version, node, bindings, locals}`
(`Conversation.cpp:283-292`、`:296-308`),即**稳定引用而非指针**。L1 必须延续这个形态
(含 version 校验,`:327-329`、`:346-353`),不得简化成裸 id。
4. **`advance` 对 choice 的拒绝**:`advance` 在 choice 节点必须返回
"select a choice route instead"(`Conversation.cpp:401-402`),不能被通用化掉。
另外 `runUntilBlocked` 的 10000 步执行预算(`Conversation.cpp:168`)必须保留——
它是脚本内容写错死循环时的唯一护栏,属于通用行为。
### 5.6 命令请求与响应
通用层(L1)——值类型与今天逐字一致,不引入新转换:
```text
SequenceCommandRequest { name, arguments, bindings, locals } // 全部 eve::Value,同今天 CommandRequest
SequenceCommandResponse { Status(Completed|Blocked|Failed), value, error }
SequenceCommandHandler = std::function<SequenceCommandResponse(const SequenceCommandRequest&)>
```
帧内 bindings/locals 继续用 `eve::StateValue`(今天 `ConversationRunner::Frame` 就是这样),
与 canonical `eve::Value` 的边界保持今天的模样:命令请求/响应走 `eve::Value`
(`CommandRequest` 今天即如此,`DialogueState.h:39-41`),legacy handler 走 `StateValue`
(`Conversation.h:97` 的 `CommandHandler` 今天即如此)。
dialogue 侧保留领域扩展(`CommandRequest` 继续带 `payment` / `stateMutations` / `kind`,
`DialogueState.h:36-46`),由 dialogue 注册的 handler 做适配转换。
**不得**把 `PaymentSpec` / `StateMutation` 提到 L1——它们属于 dialogue 的事务语义。
### 5.7 挂起、事件与条件
- 挂起:`Blocked` → `blocked_ = true` + `waitingCommand_ = true` → 宿主
`advance()` 或 `resumeCommand(value)` 继续。语义不变。
- 事件:运行时发出通用事件(`Started/NodeEntered/Command/Ended/Failed`);
步骤 handler 发出领域事件(`Line/Choice/…`)。`setEventSink` 保持单一出口。
- 条件:`setConditionEvaluator` 继续委托 `eve::decision::ConditionResult`,
dialogue 侧的 legacy 分支表达式兼容路径(`evaluateRoute`,`Conversation.cpp:140-165`)
留在 dialogue 的 evaluator 实现里。
### 5.8 热重载与存档迁移
沿用 `StoryEvent` 已证范式(`StoryEvent.h:61-65`、`docs/usr/modules/rpg.md:274-276`):
- 内容目录严格校验 + 原子替换,失败保留上一份。
- 活动会话持**定义副本**,所以目录热替换不产生悬垂引用。
- 捕获的状态用**稳定 id + version** 引用资产,恢复时按新定义重新解析(§5.5 第 3 点)。
- 存档迁移 `SequenceSaveMigrations` 的规则全是资产级事实
(`ConversationPersistence.h` 的 `Rule{assetId, fromVersion, currentAssetId, nodes}`),随之下沉。
- 事件中存档 → 读档后重放尚未确认的步骤(`examples/rpg-classic/main.nut:1967-1998`
已有该行为雏形)。
- 脚本侧热重载沿用 `filesystem::HotReload` + `Runtime::reload(ScriptId)`,本设计不新增重载机制。
### 5.9 语言兼容性契约
前端下沉是**改写**而非移动(今天的 conversation 方言没有词法器,pool 方言有),
因此验收必须钉住「接受集不变」:
- 现有 `.dnut` 源码(`examples/dialogue/pools.dnut`、
`examples/rpg-classic/data/village-dialogue.dnut` 及测试内联源)必须编译出**等价资产**:
相同的 `id`/`version`/`entry`/`parameters`、相同的节点 id 与类型、相同的路由与目标、
相同的诊断(`ConversationDiagnostic` → L1 `DnutDiagnostic`,字段逐字保留:
severity/path/line/message)。
- **行为改善处需显式声明**:今天是 `parseDnut` 无法读含 `conversation` 块的文件
(`DnutParser.cpp:414-415`),统一前端后两种方言可在同一文件被同一入口读入。
这是有意扩大的接受集,必须在文档与测试中写明,不能作为「无声兼容」。
- 语法收紧要禁止:不得因为改写而拒绝今天能编译的源。
## 6. 分层影响与模块声明
- **新建 L1 模块 `dnut_interpreter`。** 声明插入
`cmake/module_manifest/core_foundation.cmake` 的 `# L1 -- sensing/action protocol`
段(`:133`)内、`action` 声明块(`:145-147`)之后、`# L2 -- combat resolution`
(`:148`)之前——`scripts/check_module_manifest.py:216-227` 要求声明必须落在匹配的
`# L<n>` 段内。
- **声明形态**:
```cmake
# .dnut authored-sequence language, compiler, step-kind registry and cross-frame
# interpreter. Domain vocabularies (dialogue lines/pools, RPG story steps) are
# registered by their owning modules; this core never interprets a domain payload.
eve_declare_module(NAME dnut_interpreter LIB EVDnutInterpreter LAYER 1
SCRIPT DnutInterpreter SLOT dnutInterpreter
DEPS decision
GROUP minimal 2d 3d web)
```
`GROUP` 取值见 §11 待决问题 3;`SCRIPT`/`SLOT` 见 §11 待决问题 4。
`LIB`+`SCRIPT` 同时使用有先例:`rpg LIB EVRPG LAYER 1 SCRIPT RPG`(`:102`)。
- **依赖合法性**:`dialogue`(L6)→ `dnut_interpreter`(L1)、`rpg`(L1)→
`dnut_interpreter`(L1)都是合法下行/同层依赖;`scripts/module_depgraph.py:179`
明确「Same-layer edges (e.g. window -> image) are allowed」,同层先例是
`rpg`(L1)→ `action`(L1)。`dnut_interpreter` 不依赖 dialogue/rpg,无环。
- **`rpg` 的依赖声明**:`core_foundation.cmake:102-103` 的 `DEPS` 增加 `dnut_interpreter`。
- **`dialogue` 的依赖声明**:`orchestration.cmake:238-241` 的 `DEPS` 增加 `dnut_interpreter`。
`dialogue` 无 `GROUP`,加依赖不改变任何裁剪档位。
- **裁剪影响**:`dnut_interpreter` 的消费者(`dialogue`/`rpg`)都不声明 `GROUP`,
因此它不应声明 `GROUP`(§11 待决问题 3);若声明 `GROUP minimal 2d 3d web`
会被每个裁剪档无条件编入。裁剪声明按 AGENTS.md 要求给出层号与符号/构建证据。
## 7. 影响面清单
**新增**
- `src/modules/dnut_interpreter/`:`DnutLexer`、`DnutBlockScanner`、`DnutCompiler`、
`DnutLint`、`SequenceAsset.h`、`StepKindRegistry`、`SequenceRuntime`、`SequenceDocument`、
`SequenceSaveMigrations`。
- `test/dnut_interpreter_sequence.cpp`(控制流 + 挂起/恢复 + 捕获重建 + 预算护栏 + 注册表校验)、
`test/dnut_interpreter_language.cpp`(词法/块切分/编译/lint/方言共处 + 诊断保真)。
测试按 AGENTS.md 独立成文件,不进共享 test main。
**迁移(dialogue 内)**
- `DnutParser` 的 `tokenize()`(`DnutParser.cpp:101-217`)→ L1 `DnutLexer`;`parseDnut` 保留在
`dialogue`,改为消费 L1 词法与顶层块切分。
- `ConversationCompiler.{h,cpp}`、`Conversation.{h,cpp}`、`ConversationToolchain.{h,cpp}`、
`ConversationAuthoring.{h,cpp}`、`ConversationPersistence` 的迁移部分 → L1。
- `ConversationImporter.{h,cpp}`、`ConversationLocalization.{h,cpp}`、`ConversationText.{h,cpp}`、
`DialogueFlow.{h,cpp}`、`dialogue/editing/DialogueGraph*`、`dialogue/editor/*` → 改为消费 L1 资产模型。
- `DialoguePayment.{h,cpp}`、`DialogueState.{h,cpp}`、`Dialogue.{h,cpp}`、`DialogueUX.*`、
`DialogueVoice.*` → 原地不动(领域语义)。
**测试**:`test/dialogue_conversation.cpp`、`test/dialogue_compiler.cpp`、
`test/dialogue_proc.cpp`、`test/dialogue_toolchain.cpp`、`test/dialogue_authoring.cpp`、
`test/dialogue_persistence.cpp`、`test/dialogue_localization.cpp`、`test/dialogue_importers.cpp`
跟随类型变化;`test/rpg_classic.cpp:242`、`test/rpg_classic_playthrough.cpp:130` 直接使用
`compileDnutConversations`,需改为 L1 入口。
**文档**:新增 `docs/dev/dnut_interpreter 序列语言与解释器.md`(模块 owner 文档,
六面边界与裁剪声明写在这里);更新 `docs/usr/modules/dialogue.md:168-180,275`(`.dnut` 语法
与 `loadPoolsFromDnut` 的归属指针)、`docs/dev/对话与Avatar模块设计.md:220`(`.dnut` 内容格式的
owner 变更)、`docs/dev/战斗系统与动作编辑器.md`(`action` 仍是单动作管线,序列语言不在其中)、
`docs/usr/MODULES.md:75`(若新模块有用户文档则登记)。
**示例**:`examples/dialogue/main.nut:133-135`(`loadPoolsFromDnutFile` 热重载路径)、
`examples/rpg-classic/main.nut:439-442,573`(`dialogueFlow.loadFromDnut`)在阶段 1 保持可用,
但内部路径改为 L1 前端。
## 8. 分阶段落地
1. **模块与语言(阶段 1,一个 PR)**:新建 `dnut_interpreter`(词法 + 块切分 + conversation
编译/lint + 资产模型 + 步骤类型注册表 + 运行时 + `SequenceDocument` + 迁移),
并**在同一个 PR 内**把 `dialogue` 全部消费者切过去(§7)。
验收 = 现有 dialogue/rpg 测试全绿 + §5.9 的接受集契约。
AGENTS.md 的「接口变化一个 PR」要求不留中间破坏 CI 的 commit,所以阶段 1 不能只做一半。
2. **rpg 接入(阶段 2,一个 PR)**:`StoryEvent` 改用通用运行时;注册五个既有步骤类型;
保持 `eve.rpg.story-events` v1 的线格式与游标语义完全兼容;宿主脚本
`examples/rpg-classic/main.nut:714-753` 的 if/else 链退役。
3. **补表现命令(阶段 3)**:`story:animation`、`story:expression`、`story:wait-for-motion`、
移动路线(基于既有 `map::Pathfinder` + 一个按格移动控制器;
`docs/dev/2.5D-tilemap-developer-evaluation.md:61` 已记录该缺口)。
4. **补控制流与脚本扩展面(阶段 4)**:`Loop / Label+Jump / CallCommonEvent / Parallel`,
块类型注册入口 `BlockKindRegistry`,脚本侧注册步骤/块的能力,以及 lint 与 i18n 抽取的扩展。
阶段 1、2 是「不改内容格式」的架构收敛;阶段 3、4 是可交付能力,各自独立 PR。
## 9. 验收与门禁
阶段 1 的机械验收:
```sh
python3 scripts/check_module_manifest.py # 新声明落在 L1 段内、目录存在
python3 scripts/module_depgraph.py --check-layers # L1 不出现对 L6 的依赖
ARCHITECTURE_BASE=HEAD make check/architecture-contracts
make check/quality-metadata
git clang-format # 只格式化改动行
```
行为保持验收(必须覆盖 §5.5 的四处):
- `resumeCommand`:`Blocked` → `advance`/`resumeCommand` → 继续的两条路径行为不变。
- `selectRouteForTransaction`:成功提交与失败保持游标两种情况不变;
`advance` 在 choice 上仍拒绝。
- `captureState`/`restoreState`:含 0 层与多层调用栈、bindings/locals 保真、
asset version 不匹配时明确失败。
- 执行预算护栏:构造超长/自环内容必须仍以预算耗尽失败,不挂死。
- 语言接受集:§5.9 的等价性用例(既有源 → 等价资产 + 等价诊断)。
契约条目:本设计改变的是「谁拥有 `.dnut` 的语法、序列游标与命令派发」,属可执行的
`state-owner` 与 `api-lifetime` 范围,按需在 `scripts/architecture_contracts.json` 补齐条目。
> **门禁陷阱(已核对,勿踩):** AGENTS.md 要求新模块补 `module-interface` catalogue 条目,
> 但 `scripts/check_architecture_contracts.py:47-58` 的 `RULES` 里没有 `module-interface`
> (实际为 `api-shape`/`link`/`state-owner`/`ecs-system`/`time-rng`/`api-lifetime`/`persistence`/
> `optional-capability`/`backend-contract`/`debt-metadata` 十项),写这种条目会被判
> `rule must be one of …` 直接失败(`docs/dev/2026-09-15-模块边界审查台账.md:199`
> 也确认该机制尚未存在)。本设计**不**往 `architecture_contracts.json` 写非法 rule;
> 新模块的六面边界写进它的 owner 文档,并用可执行的 `state-owner`/`api-lifetime` 条目表达。
## 10. 非目标
- 不改变 `eve.rpg.story-events` / `eve.dialogue.*` 的既有线格式与存档兼容性。
- 不把 `PaymentSpec` / `StateMutation` / i18n / voice / 台词池等 dialogue 事务语义提到 L1。
- 不做脚本侧动态注册原生控制流(`std::function` 无法跨 C++/Squirrel);脚本侧仍只可注册
策略与命令 handler,控制流由 C++ 拥有。
- 阶段 1、2 不新增任何面向玩家的能力,只做架构收敛。
- 不把 `action::ActionTimeline` 的语义轨道采样并入本模块(动作内采样器与场景级序列是
两个粒度,各自保留)。
## 11. 待决问题(实施前需定)
1. **payload 的值类型**:建议统一用 canonical `eve::Value::Object`(与 `CommandRequest` 一致)。
代价是 `Node::arguments` 今天存 `StateValue`,payload 化后需要在边界转换;
必须用 §5.5 的双重重载与等价测试证明零行为变化。
2. **`pool` 块的处理深度**:本设计取「阶段 1 共用 L1 词法与顶层块切分,池语法解析仍归
dialogue」;完整方案是 `BlockKindRegistry` 让块类型成为注册项(阶段 4)。
若实施中发现共享词法不足以表达池语法的行敏感属性(`parseAttrs` 用 `cur().line` 判定,
`DnutParser.cpp:336-337`),需要在阶段 1 就引入 `BlockKindRegistry`。
3. **`GROUP` 取值**:推荐不声明 `GROUP`(与 `rpg`/`dialogue` 一致,只随完整构建进入);
备选 `minimal 2d 3d web`(每档都编入)。以 `scripts/profile_matrix.py` 输出取证后定。
4. **`SCRIPT` 面的范围**:推荐阶段 1 就声明 `SCRIPT DnutInterpreter SLOT dnutInterpreter`
并只暴露只读/装载绑定,步骤与块的脚本侧注册留到阶段 4;若认为这构成未使用的门面,
可先 LIB-only,但后续必须补一次「加 SCRIPT」的接口变更。
5. **命名空间与类型名**:模块名 `dnut_interpreter` 按惯例命名空间同名
(`eve::dnut_interpreter`),但较长。候选 `eve::dnut`。需与 `eve::EventSequence`
(`common/EventSequence.h`,事件流内序号,123 处引用)在阅读上明确区分——
本设计不使用 `EventSequence`/`Sequence` 作为模块名。
## 12. ADR 信号
本设计触及持久架构面:**模块新增**(L1 `dnut_interpreter`)、**owner 变更**
(`.dnut` 语法、资产模型、序列游标与命令派发的 owner 从 L6 移到 L1)、
**公共契约变更**(`ConversationAsset`/`ConversationRunner` 被取代)、
**依赖方向变更**(`dialogue` → `dnut_interpreter`、`rpg` → `dnut_interpreter`)、
**源码真相源迁移**(语言前端与作者反射从 dialogue 迁到 dnut_interpreter)。
实施完成后应补一条 ADR,并在 baseline 同步时回答:
`action` 是否仍是「单动作生命周期」的唯一 owner(本设计回答:是),
以及 `dnut_interpreter` 是否同时承担了「语言」与「运行时」两个职责而需要再切分(待验证)。
+9 -7
View File
@@ -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
+204 -14
View File
@@ -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
推进;各状态仍由所属模块唯一拥有。
+119
View File
@@ -0,0 +1,119 @@
# 程序化道路系统设计
日期:2026-09-17
状态:MVP 已落地(`src/modules/procgen/road/`)
存放:`docs/dev/`
## 目标
在**不使用预制道路/桥梁模型**的前提下,用数学函数生成与参考图一致的视觉层:
1. **几何**:多高程道路、桥面、侧栏、人行道、桥墩
2. **逻辑贴图**:沿弧长/横向 UV 的车道线、斑马线、导向箭头
3. **导航网络可视化**:车道中心线与路口转弯弧(青/绿叠加)
全部由 `SplinePath` 平行输运帧 + 截面挤出 + 图论车道图合成,运行时可烘培为 `MeshBuild`。
## 非目标(MVP)
- 完整路口 CSG 布尔融合(用路口平台 + 边端收缩近似)
- 地形挖填 / 贴地切割
- 真实交通仿真与 AI 寻路驱动
- 中文「道路编辑」产品化编辑器面板(文档预留工具流,运行时先配方 + 示例)
## 与现有能力的关系
| 复用 | 角色 |
|------|------|
| `SplinePath` / `sampleFramesResult` | 中心线与 Frenet 式截面帧 |
| `MeshBuild` 命名 group | `asphalt` / `curb` / `sidewalk` / `deck` / `pier` / `marking` / `nav` |
| `MeshRecipeRegistry` | `mesh.roadNetwork` 配方入口 |
| `TextureRecipeRegistry` | `tex.roadMarkings` 逻辑贴图 |
| `urban` 街道 | 可选上游 2D 中心线;本系统是 3D 车道域 |
**不**新建顶层 `road` 模块目录;算法作为 `procgen` 宿主内的 `road/` 卫星(同 `urban/`)。
## 数据模型
```text
RoadNetwork
nodes[]: RoadNode { id, position, junctionRadius }
edges[]: RoadEdge {
id, from, to,
controlPoints[] → SplinePath (catmullRom),
lanesForward, lanesBackward,
style (laneWidth, curb, sidewalk, deckThickness, pier*),
elevated (bool | height threshold)
}
laneLinks[]: at a node, (inEdge, inLane) → (outEdge, outLane)
```
权威所有者:`RoadNetwork` 拥有节点/边/车道连接;烘培结果是无指针快照(`MeshBuild` + `RoadOverlay` 折线)。
## 数学管线
### 1. 截面剖面 `RoadProfile`
在边局部 `(side, up)` 平面构造折线剖面:
```text
[-W/2-sidewalk-curb … asphalt lanes … +W/2 …]
```
每个区段带 material tag,挤出时写入对应 triangle group。桥面在 `up` 负向加 `deckThickness` 底板。
### 2. 边挤出 `RoadBake::bakeEdge`
1. 边控制点 → `SplinePath`,弧长均匀采样帧
2. 在路口半径处裁剪端点参数,避免与路口平台重叠
3. 沿帧挤出剖面(开剖面条带 + 底板可选)
4. 若帧高度 > `pierClearance`,按 `pierSpacing` 放置轴对齐桥墩盒子
5. 车道标线:在沥青表面上偏移的薄带(白边线、黄虚中线、斑马带)
UV:`u = normalized arc length`,`v = normalized lateral`(逻辑贴图与虚线周期均依赖此约定)。
### 3. 路口平台
对度数 ≥ 2 的节点,用入射边端点左右路肩点构成凸包近似平台,扇形三角化;斑马线沿入射方向铺条。
### 4. 导航叠加 `RoadOverlay`
- 每条车道:中心线偏移 `±(i+0.5)*laneWidth` 的折线
- 路口:同节点合法 `laneLinks` 用三次 Hermite 弧连接
- 箭头:沿折线按弧长放置小三角 mesh(group `nav`)
### 5. 逻辑贴图 `tex.roadMarkings`
纯函数 `(u,v) → RGBA`:沥青底色 + 边线 + 周期虚线 + 可选斑马/箭头 mask。与几何 UV 对齐,可独立预览或烘焙。
## 编辑工具流(产品映射)
与参考图底部工具栏对齐的**语义阶段**(实现可后挂 `procgen_editing`):
| 阶段 | 文档操作 | 运行时对应 |
|------|----------|------------|
| 道路编辑 | 增删边控制点 | `RoadEdge.controlPoints` / SplinePathDocument |
| 岔路编辑 | 节点连接、匝道 | `RoadNode` + 边拓扑 |
| 导航网络 | 生成 laneLinks / overlay | `buildNavigation` |
| 调整车道 | lanesForward/Backward、laneWidth | `RoadStyle` |
| 烘培模型 | bake → MeshBuild | `bakeRoadNetwork` / `mesh.roadNetwork` |
| 样式编辑 | 剖面与材质色 | `RoadStyle` + `tex.roadMarkings` |
## API 契约
- 失败路径一律 `Result<T>` / recipe `bool + error`;禁止 `lastError`。
- 烘培同步、owner-thread-only;返回值不持有对 `RoadNetwork` 的指针。
- Schema:`eve.procgen.roadNetwork` v1(后续 Document 化时沿用)。
## 验证
- 单元:剖面宽度、挤出顶点/组、桥墩触发、导航折线非空、配方确定性
- 示例:`examples/procedural-road` 烘培立交并画导航折线
- 架构:仍落在 `procgen` 宿主内,无向上 `#include`
## 后续迭代
1. 路口 curb return / 真正的 mesh stitch
2. `RoadNetworkDocument` + gizmo(复用 SplinePathGizmo)
3. 地形 conform + 走廊 mask
4. 与 `urban` 中心线自动导入
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -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()` 定义逐骨权重。
+13
View File
@@ -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)` 原子应用命令。
+31
View File
@@ -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 状态一起逆序恢复。
+61
View File
@@ -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 中复制冷却、动作阶段或生命值。
+128
View File
@@ -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);
+329 -2
View File
@@ -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()` 返回最近状态与采样坐标。
+347
View File
@@ -0,0 +1,347 @@
# 六边形立体地图(Hex Map)
**脚本入口:** `hexmap`(`eve.HexMap`,模块槽位 `hexmap`)
`hexmap` 是引擎的**可编辑 3D 六边形地图**层:持有尖顶(pointy-top)六边形格网、
按 5×5 分块、按面(terrain / water / river / road)生成 CPU 网格并上传 GPU,同时
提供世界坐标拾取、邻居查询与笔刷编辑。参考实现是 Catlike Coding 的
[Hex Map](https://catlikecoding.com/unity/hex-map/) 工程;坐标、高程台地、悬崖、
水面与河道几何均与之一致。
## 布局与常量
| 项 | 值 | 说明 |
|----|----|------|
| 朝向 | 尖顶 | XZ 平面,+X 向东,+Z 向北,Y 为上 |
| `outerRadius` | 10 | 六边形外接圆半径 |
| `innerRadius` | 8.66025404 | `outerRadius * √3/2` |
| `elevationStep` | 3 | 每级高程的世界高度 |
| `solidFactor` / `waterFactor` | 0.8 / 0.6 | 实心区与水面内缩因子 |
| `terracesPerSlope` | 2(`terraceSteps` = 5) | 斜坡台地级数 |
| `kChunkSizeX/Z` | 5 / 5 | 分块尺寸;地图尺寸必须是它的整数倍 |
| 地面扰动 | XZ ±4,Y ±1.5 | `HexNoise`(种子决定,无需贴图资源) |
偏移坐标到轴坐标:`axial = offset - (z/2, 0)`(`z/2` 向下取整)。世界坐标:
`x = (axialX + 0.5·axialZ) · innerDiameter`,`z = axialZ · outerRadius · 1.5`。
`HexCoordinates::fromWorldPosition` 是其精确逆变换(含立方体取整修正)。
## C++ API 快查
### `HexMetrics`([HexMetrics.h](../../../src/modules/hexmap/HexMetrics.h))
常量与角点/边采样助手:`firstCorner` / `firstSolidCorner` / `firstWaterCorner` /
`bridge` / `waterBridge` / `solidEdgeMiddle` / `terraceLerp` / `wallLerp`,
以及 `elevationY` / `streamBedY` / `waterSurfaceY`。`EdgeVertices` 是边上 5 个
采样点(v1…v5)。
### `HexCoordinates`([HexCoordinates.h](../../../src/modules/hexmap/HexCoordinates.h))
轴坐标 `(x, z)`,`y = -x - z`。`step(direction, distance)`、`distanceTo`、
`fromOffset` / `offsetX` / `offsetZ`、`chunkColumn` / `chunkRow`、
`toWorldPosition` / `fromWorldPosition`。
### `HexMap`([HexMap.h](../../../src/modules/hexmap/HexMap.h))
值类型格网,`reset(cellCountX, cellCountZ, seed)` 建立地图(尺寸必须是 5 的倍数)。
- 拓扑:`contains` / `indexOf` / `coordinatesAt` / `tryGetNeighbor` / `edgeTypeTo`
- 分块:`chunkIndexOf` / `chunkColumnOf` / `chunkRowOf` / `chunkCenter` /
`takeDirtyChunk` / `markAllChunksDirty` / `dirtyChunkCount`
- 拾取:`pickCell(rayOrigin, rayDirection)` —— 无碰撞体,改为在候选格高程平面上
迭代求交并二分收敛
- 查询:`elevation` / `waterLevel` / `terrainType` / `urbanLevel` / `farmLevel` /
`plantLevel` / `specialIndex` / `isUnderwater` / `hasRiver` / `hasRoad` / `isWalled`
- 编辑:`setElevation` / `setWaterLevel` / `setTerrainType` / `setUrbanLevel` /
`setFarmLevel` / `setPlantLevel` / `setSpecialIndex` / `setWalled` /
`setOutgoingRiver` / `removeRiver` / `addRoad` / `removeRoads`
- 笔刷:`editElevation` / `editWaterLevel` / `editTerrainType` / `editFeatureLevel`
(六边形圆盘,`collectBrush` 可单独枚举)
跨格约束与参考工程一致:河流只能顺坡或从湖面流出;道路不能跨两级以上高程、
不能穿过河流中段、城市/特殊地物会移除道路;特殊地物在有河时被忽略。
### 网格生成([HexMapMesh.h](../../../src/modules/hexmap/HexMapMesh.h))
`buildChunkSurfaceMesh(map, chunkIndex, surface, out)`,`surface` 取
`HexSurface::{Terrain, Water, River, Road}`。生成物是 `HexMeshData`
(position / normal / uv / index,三角形不共享顶点,`finalize()` 写平面法线)。
分块所有权与参考工程一致:地面扇形按本块 25 格生成,**只有 NE / E / SE**
方向的混合带、台地与悬崖归属本块,其余由邻块生成,因此块与块之间不会重复或漏缝。
顶点只有两个纹理坐标,地形三层信息按
`u = layerA + layerB·8 + layerC·64`、`v = weightB + weightC·16` 打包;这是当前
顶点布局的**兼容桥**,不是地形数据格式。
`HexSurface::Fog`(值 4)是唯一不能由 `buildChunkSurfaceMesh` 生成的流,因为它
除了格子还需要可见性计数器,由 `buildFogMesh(map, visibility, chunkIndex, out)`
生成:每个"当前不可见"的格子输出一根六棱柱(顶面 + 侧裙),裙脚垂到最低可编辑
高程之下,因此迷雾边界上的悬崖立面也会被覆盖;第一纹理坐标 `u` 承载明暗
(`0` = 曾经探索过但此刻无人看见,`1` = 从未探索)。迷雾流在可见性跨越 0/1 时
自动把所在块标脏。
另外两条流由 [HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h) 生成,也不走
`buildChunkSurfaceMesh`:`HexSurface::Wall`(值 5,`buildWallMesh`)输出城墙、墙塔
与桥梁,`HexSurface::Feature`(值 6,`buildFeatureMesh`)输出 urban / farm / plant /
special 地物。两者共用一条片元着色器,因为第一纹理坐标已经标明了部件种类:
| `u` | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| 部件 | 城墙 | 墙塔 | 桥梁 | urban | farm | plant | special |
城墙的归属规则与地形一致:只有当 `d ∈ {NE, E, SE}` 或该方向已经在网格之外时,
才由本块生成该边的城墙——这样每条内部边恰好被一侧生成,而地图边界仍然闭合。
道路横穿城墙边界时会开出城门(只生成两侧外段 + 两个端帽);高程差产生楔形挡墙。
### 地物与城墙([HexFeatures.h](../../../src/modules/hexmap/HexFeatures.h))
参考工程实例化 prefab 模型;本引擎不携带资源,因此 `HexFeatures.cpp` 用程序化几何
替代(墙=两侧面 + 顶盖,塔/桥/建筑=盒体,plant=压扁八面体,special=锥形柱)。
`HexHashGrid` 是参考工程 `hashGrid` 表的确定性替代:由 `(gridX, gridZ, seed,
channel)` 的整数哈希直接导出五个分量,因此不需要分配表、也没有全局状态,同一地图
种子永远得到同一套地物布局。`featureThreshold(level, index)` 返回参考工程的
三级阈值表 `{0,0,0.4} / {0,0.4,0.6} / {0.4,0.6,0.8}`,越界参数被钳制。
拾取规则与参考工程一致:urban / farm / plant 三个集合各自用格子哈希判定是否
命中,命中的里面哈希最小者胜出;`specialIndex` 非零时该格不生成普通地物。
### 地图生成([HexMapGenerator.h](../../../src/modules/hexmap/HexMapGenerator.h))
`generateHexMap(map, settings)` 把参考工程 `MapGenerator` 的流水线移植为纯 CPU 步骤:
1. 全部格子初始化到 `elevationMinimum`;
2. `regionCount` 个陆地区域从种子格生长(抖动、随机抬升/下沉,遵守地图与区域边界);
3. 海岸线侵蚀直至陆地占比接近 `landPercentage`;
4. 顺坡开凿河流(含湖泊概率与已有的汇流判定);
5. 40 轮下风向水汽传输(蒸发、降水、坡面径流、下渗),得到湿度与温度;
6. 按参考工程的生物群系表写地形类型与植被等级。
可复现性只依赖 `settings.seed` 与网格本身:不使用时间、`random_device` 或可变静态
状态。`elevationMaximum` 会被钳制到 `HexMetrics::kMaxElevation`;不合法或放不下的
区域参数在改动地图之前就以 `InvalidArgument` 拒绝。生成后的地图**全部格子未探索**,
因此调用方必须重新放置单位并重建全部块。
### 搜索与寻路([HexSearch.h](../../../src/modules/hexmap/HexSearch.h))
- `HexSearchContext`:可复用的搜索草稿(每格一条 `HexSearchData` + 按优先级分桶
的前沿队列)。用**相位**(`beginPhase()`,每次 +2)代替"清空 visited",因此一次
搜索不付全图重置的代价。
- `isValidDestination(map, c, occupied)`:`explored && explorable && !underwater &&
!occupied`;地形部分由 `HexCell.h` 的 `canHoldUnit()` 单点定义。
- `moveCost(map, from, to, direction, occupied)`:悬崖与墙界不可通行;起点有路 = 1,
否则平地 5 / 斜坡 10,再加目标格的 urban+farm+plant 等级。返回**负数**表示阻断。
- `findPath(map, scratch, from, to, rules, occupied) -> Result<HexPath>`:A\*(启发式
为六边形距离),代价按 `speed` 折算成"回合",`HexPath::turns` 给出每个格子到达
时的回合数。目标不可进入时返回 `NotFound`,端点越界或 scratch 尺寸不匹配返回
`InvalidArgument`。
- `collectVisibleCells(map, scratch, from, range, out)`:视线可达格(距离 + 目标格
`viewElevation` 与 `range + 起点 viewElevation` 比较,且不超过直接六边形距离)。
### 可见性与迷雾([HexVisibility.h](../../../src/modules/hexmap/HexVisibility.h))
`HexVisibility` 是**引用计数**而非布尔:多个观察者重叠时,只有最后一个离开才让格子
转回不可见;某格第一次被看见时同时把地图上的 `explored` **单向闩锁**置位,此后不再
回落。`increase` / `decrease` / `clear` / `resetVisibility`(按当前单位重算全图)。
### 单位([HexUnits.h](../../../src/modules/hexmap/HexUnits.h))
`HexUnitRegistry` 持有单位、寻路占用与视野贡献:
- `addUnit` 立即授予视野;`unitIdAt` / `isOccupied` 用于寻路占用谓词
(`occupancyQuery()` 返回借用 `*this` 的可调用对象)。
- 单位 id 就是注册表下标,删除会让后面的 id 前移;调用方不要跨删除缓存 id。
- 移动沿二次贝塞尔曲线(Unity 参考工程的转角做法),`advance(map, visibility,
scratch, id, dt)` 既推进动画又返回**当前位姿样本**(世界坐标已含高程与扰动),
`dt <= 0` 表示只读不推进。**目的地自 `beginTravel` 起即被占用**,视野则跟着
实际走过的格子移动(`visionIndex`)。
- `restore` 先清空再校验,被拒绝的载荷不会留下"半恢复"的注册表。
### 存档([HexSerializer.h](../../../src/modules/hexmap/HexSerializer.h))
`saveHexMap(map, units, out)` / `loadHexMap(bytes, map, units)`:小端、显式逐字节
写出的自描述载荷(magic `EVEHEX\0\0` + 版本 + 尺寸 + 种子 + 每格 `HexValues` /
`HexFlags` + 单位表)。版本不匹配会**拒绝**而不是猜测;载荷在触碰输出前整体校验
(长度、magic、版本、尺寸上限 512 且为 5 的倍数、单位格在界内、单位格可站立、
单位不重复),因此被拒绝的载荷不会留下部分写入的地图。修订号字段只作诊断。
### 球面后端([HexSphereTopology.h](../../../src/modules/hexmap/HexSphereTopology.h)、[HexSphereMap.h](../../../src/modules/hexmap/HexSphereMap.h)、[HexSphereMesh.h](../../../src/modules/hexmap/HexSphereMesh.h)、[HexSphereGenerator.h](../../../src/modules/hexmap/HexSphereGenerator.h))
前面所有小节都是**平面**地图:单元身份是 `HexCoordinates`,邻居是固定的整数方向偏移。
球面后端复用同一套单元记录(`HexCellData` / `HexValues` / `HexFlags`)、同一套几何常量
(`HexMetrics`)、同一个 `HexMeshData` 与同一份顶点编码,只替换这两件事——因为在闭合
球面上它们才真正不成立:
- **单元身份**是稠密的 `HexSphereCell`(`int32`),来自**二十面体(Goldberg)拓扑**:
二十面体按 `f = 2^subdivision` 细分后的**顶点**就是单元,于是
`cellCount = 10f² + 2`,其中恰好 12 个是五边形(恒为 `[0, 12)`,因为中点细分从不
重编号原始顶点);`edgeCount = 30f²`,`cornerCount = 20f²`,每个角点恰好被 3 格共享。
- **邻居关系**来自半边表。**方向不是算术**:六条边的方位逐格变化,而五边形只有五条边,
因此不存在处处成立的 `opposite(direction)`——所有跨边查询走
`directionOf(cell, other)`。方向 `d` 的边位于 `corner(d)` 与 `corner(d+1)` 之间,
这两个角点由该边两侧的单元**以相反顺序**共享。
`HexSphereMap` 是 `HexMap` 的球面对应物:持有单元存储、`HexNoise`、**逐格**脏标记
(球面没有分块网格,拓扑已经给每格一个全局稳定 id)、拾取、距离、刷子与全套编辑接口。
高程是**径向**的:`surfaceRadius = sphereRadius + elevation · elevationStep`。
`elevationStep` 默认取半径的 `0.006`,而不是平面的 `HexMetrics::kElevationStep`
(3 世界单位)——后者在半径 100 的星球上会让 13 级高程跨越 39% 半径。`HexFlags` 的
道路/河流 6 个 bit 槽直接由方向索引寻址,五边形上槽 5 永不置位。
`buildSphereTerrainMesh` / `buildSphereWaterMesh` 沿用平面构建器的角点矩阵方案,顶点
编码(`HexTerrainVertexCode`)完全一致,因此地形着色器的读法不变。三处平面假设被替换:
1. **边数**不是 6(五边形是 5 条),没有任何循环可以写死 `kHexDirectionCount`;
2. **边归属**:平面版靠分块网格(`direction <= SE`)保证每条共享边只发一次,球面改用
**较小的单元 id** 拥有该边——同一组边上的全序,同样保证恰好一次;
3. **"垂直"是径向**:坡度台地沿**弧**走水平步、沿**半径**走垂直步。
平面版的梯田带用弦 `v1 → v5` 跨越整条边,这在平面上安全**只因为**它的 5 个采样点共线;
球面上采样点在弧上,弦会在每条带坡度或崖壁的边留下 T 型接缝,因此球面版按扇形同样的
4 段拆分。地形顶点扰动也改为**切向**并把结果投影回原半径:切向偏移会以 `O(d²/r)` 改变
半径,而这里扰动量是单元尺寸的可观比例。
`generateSphereMap` 采样**三维**值噪声:`HexNoise` 是 XZ 平面场,用单元方向去采样会在
两极塌缩(方向的 XZ 投影趋零)、在经度回绕处接缝。海平面由噪声自身的分位数确定,因此
`landPercentage` 真正决定陆地占比,而不是听任噪声分布。输出是
`(seed, 拓扑, 半径)` 的纯函数。
## 脚本 API(`hexmap`)
```squirrel
hexmap.newGrid(gfx, cellCountX, cellCountZ, seed) // -> Result
hexmap.hasGrid() / cellCountX() / cellCountZ() / chunkCountX() / chunkCountZ() / chunkCount() / seed()
hexmap.surfaceCount() // -> 7,见下面的 surface 编号表
hexmap.takeDirtyChunk() // -> 脏块索引,-1 表示没有
hexmap.rebuildChunk(gfx, chunkIndex) // -> 生成出几何的面数
hexmap.rebuildDirtyChunks(gfx) // -> 重建全部脏块,返回面数合计
hexmap.chunkMeshAt(chunkIndex, surface) // -> Mesh(借用),无几何时为 null
hexmap.releaseMeshes(gfx) // 释放本模块创建的全部网格
hexmap.elevation(x, z) / waterLevel(x, z) / terrainType(x, z) / urbanLevel(x, z) / ...
hexmap.isUnderwater(x, z) / hasRiver(x, z) / hasRoad(x, z) / isWalled(x, z)
hexmap.specialIndex(x, z) / farmLevel(x, z) / plantLevel(x, z)
hexmap.setElevation(x, z, v) / setWaterLevel(x, z, v) / setTerrainType(x, z, v) / ...
hexmap.setUrbanLevel(x, z, v) / setFarmLevel(x, z, v) / setPlantLevel(x, z, v) / setSpecialIndex(x, z, v)
hexmap.setWalled(x, z, v) / setOutgoingRiver(x, z, direction) / removeRiver(x, z)
hexmap.addRoad(x, z, direction) / removeRoads(x, z)
hexmap.editElevation(x, z, radius, delta) / editWaterLevel(x, z, radius, delta)
hexmap.editTerrainType(x, z, radius, terrainType) / editFeatureLevel(x, z, radius, feature, delta)
// 世界坐标(含高程与扰动);同一坐标系的逐分量版本,供脚本直接喂给 renderable
hexmap.cellPositionX(x, z) / cellPositionY(x, z) / cellPositionZ(x, z)
hexmap.chunkCenterX(chunkIndex) / chunkCenterY(chunkIndex) / chunkCenterZ(chunkIndex)
hexmap.pickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=[x, z]}
// 迷雾
hexmap.isExplored(x, z) / isExplorable(x, z) / setExplored(x, z, v) / setExplorable(x, z, v)
hexmap.isCellVisible(x, z) / visibleCellCount() / resetVisibility()
// 单位
hexmap.unitCount() / addUnit(x, z, orientation) / removeUnit(id) / removeAllUnits()
hexmap.unitIdAt(x, z)
hexmap.unitSample(id) // -> {ok, value=[cellX, cellZ, worldX, worldY, worldZ, yaw, traveling]}
hexmap.travelUnit(id, path) / advanceUnits(dt)
// 寻路 / 存档 / 生成
hexmap.findPath(fx, fz, tx, tz) // -> {ok, value=[[x, z, turn], ...]}
hexmap.saveMap() // -> {ok, value=<二进制安全字符串>}
hexmap.loadMap(gfx, blob) // -> Result
hexmap.generateMap(gfx, seed, landPercentage, waterLevel, riverPercentage) // -> Result
// 球面(与平面地图相互独立,可同时存在)
hexmap.newSphere(gfx, subdivision, radius, seed) // -> Result;重建拓扑并释放上一颗星球的网格
hexmap.sphereReady() / sphereCellCount() / spherePentagonCount() / sphereSubdivision()
hexmap.sphereRadius() / sphereElevationStep() / sphereCellSpacing()
hexmap.sphereIsPentagon(cell) / sphereNeighborCount(cell) / sphereNeighbor(cell, direction)
hexmap.sphereDirection(cell) / sphereCornerDirection(cell, corner) / sphereCornerCount(cell)
hexmap.sphereElevation(cell) / sphereWaterLevel(cell) / sphereTerrainType(cell) / sphereIsUnderwater(cell)
hexmap.sphereCellAt(x, y, z) / sphereDistance(a, b)
hexmap.spherePickCell(ox, oy, oz, dx, dy, dz) // -> {ok, value=<cell>}
hexmap.sphereSetElevation(cell, v) / sphereSetTerrainType(cell, v)
hexmap.sphereEditElevation(cell, radius, delta) / sphereEditTerrainType(cell, radius, terrainType)
hexmap.generateSphere(gfx, seed, landPercentage, waterLevel) // -> Result;生成并重建两个网格
hexmap.rebuildSphere(gfx) // -> Result;批量编辑后重建
hexmap.sphereTerrainMesh() / sphereWaterMesh() // -> Mesh(借用),无几何时为 null
hexmap.sphereReleaseMeshes(gfx)
```
`surface` 取 `0=Terrain, 1=Water, 2=River, 3=Road, 4=Fog, 5=Wall, 6=Feature`。
`x, z` 是偏移坐标(列、行)——`pickCell` 的返回值同样是偏移坐标,与 `elevation` /
`editElevation` / `cellPositionX` 等所有格子接口同一坐标系,因此拾取结果可以直接回喂
给它们(`findPath` 与 `unitSample` 返回的格子也已经是偏移坐标)。`unitSample` 复用
`advance(dt=0)`,因此返回的是含高程与扰动的插值位姿,不是地格中心。`generateMap` 按
**当前网格尺寸**重新生成,会丢弃全部单位与迷雾状态,调用方必须在成功后重新放置单位
并重建全部块。
球面接口用 `cell`(稠密 id,`0 .. sphereCellCount()-1`),与平面接口的偏移坐标 `(x, z)`
是**两套互不相通的地址空间**:不要拿平面坐标去喂球面接口,反之亦然。`sphereDirection`
与 `sphereCornerDirection` 返回 `[x, y, z]` 单位向量。`newSphere` 只重建拓扑,
`generateSphere` 才写入单元并重建网格;两者都会释放上一颗星球的网格,因此调用方必须在
成功后重新绑定 renderable。`rebuildSphere` 是批量编辑后的重建入口——球面是**整张一个
网格**(没有分块),每次重建都是全量,应当攒够一批编辑再调一次,而不是每改一格就调。
## 生命周期与所有权
- 模块**只持有一张活动地图**(参考工程的编辑器同样只编辑一张格子);`newGrid`
会释放上一张地图生成的全部 GPU 网格。
- 模块拥有 `HexMap` 与它创建的每个 `graphics::Mesh`;`chunkMeshAt` 返回**借用**
指针,`Renderable3D` 只能在模块存活且未换图期间引用它。
- 某面在一张地图内可能从"有几何"变为"无几何"(例如最后一条道路被擦掉):此时
该面的 GPU 网格**延迟释放**(保留到下次 `newGrid`),`chunkMeshAt` 返回
`null`,调用方据此隐藏 renderable,不会出现悬垂指针。
- `loadMap` 走 `adoptGrid`:先释放旧网格、接管已解码的地图、重建搜索草稿与迷雾
计数器,再恢复单位。**调用方必须在成功后重建全部块并重新绑定 renderable**,
因为所有块都是脏的且没有任何 renderable 指向新网格。
- 主/渲染线程亲和;不调用未知回调,不跨外部调用持锁。
## 示例
`examples/hex-terrain-3d` 是本模块的完整示例(与之无关的 `examples/hex-terrain` 是
`mesh.hexterrain` 配方,保持原样):`WASD` 平移、`Q/E` 旋转、滚轮缩放,
`1`–`5` 切换高程/水面/地形/河流/道路模式,`[`/`]` 调整笔刷半径,`,`/`.` 切换地形
调色板,左右键升降或拖拽绘制河流与道路,`R` 换种子、`F` 换地图尺寸、`N` 重建。
第二轮补上战争迷雾、单位与寻路、存档:`tab` 切换选中单位,`g` 规划到光标格的路径,
`space` 出发,`escape` 取消预览,`t`/`y` 增删单位,`F5`/`F9` 存档与读档。第三轮补上
城墙/桥梁/地物与地图生成器:`m` 按当前尺寸生成一张程序化地图(重新随机种子)。
存档载荷经 `eve.Filesystem().writeText()` 落到保存目录,读回时先整体校验再接管
网格,因此损坏的文件不会破坏当前地图。
`examples/hex-planet` 是**球面后端**的示例:轨道相机绕星球旋转,`LMB` 拖拽改变
方位角/仰角,滚轮缩放,`space` 自动旋转,`R` 换种子,`[`/`]` 切换细分级别,`-`/`=`
调整陆地占比,`F5` 抓帧。默认姿态是静止的,所以两次运行抓到的画面几乎逐像素
一致(仅 ImGui 覆盖层有像素级差异)。它自带两个片元着色器:平面地形着色器把
`vWorldPos.y` 当海拔、`N.y` 当"上"、细节噪声投影到 XZ、远处用距离雾——这四件事在球面上
都不成立,因此球面版保留调色板与 BRDF、只替换这四处。另外宿主会在片元输出之后再套一层
Reinhard 风格的 tonemap(调试着色器输出常量 `1.0` 到达帧缓冲是 `224/255`,即
`y = x/(x+0.139)`),两个球面着色器都用 `hostTonemapInverse` 反解这条曲线,否则颜色会被
压缩两次且无法调参。
相机方面示例做了一个必要的修正:`eve.Camera3D()` 的默认裁剪范围是 `0.1..100`,
比一张地图的跨度还小,超过 100 世界单位的地形会被静默裁掉,远端的缩放档位因此只
能看到空屏。示例现在每帧按地图跨度推导远裁剪面,并让最远缩放距离随之放大
(`ZOOM_FAR_MARGIN`),所以任何地图尺寸都能完整入画。
## 已知边界(尚未实现)
瀑布与河口(estuary)几何尚未移植;河道是等宽中心线带,未做 7 分支交汇解算;迷雾按
"已探索 / 当前可见"两态渲染(不区分"当前可见"之外的中间态透明度),且迷雾柱顶取
自身与邻居的最高面,因此在高差极大的边界上会比参考工程略"鼓";地物是程序化几何
而非参考工程的 prefab 比例。
水面与参考工程逐方向对齐:邻居是水就发一个中心三角形 + 桥接四边形(桥接只由
NE/E/SE 侧发,避免水/水边界重复),邻居是陆地就发四个细分楔形三角形 + 四段岸带;
岸带**平铺在水面高度**,只借用邻格的实体角点平面位置,不借用它的高程——倾斜到岸顶的
写法会在每条六边形边上留下一条亮带。`HexSurface::Water` 因此完全水平,测试
`hexmap.mesh.waterSurfaceIsFlatAtAShore` 会断言所有水面顶点同高、所有法线朝 +Y。
地图生成器的两处刻意偏离参考工程(均已在源码中注释):侵蚀取共享搜索前沿中最低的
格子,而参考工程是均匀随机挑选可侵蚀格;温度抖动使用私有的值噪声,而参考工程采样
噪声贴图。生成器的 `HemisphereMode` 只实现参考工程的 `North` 映射。
球面后端的已知边界:球面**没有**河流、道路、城墙、地物、迷雾与单位的网格与查询接口
(`HexFlags` 的位可以设置,但没有对应的球面几何与寻路);海洋是每格一个六边形盖面,
没有浅滩、折射与岸线浪花,且按模块自身的规则 `waterLevel == elevation` 不算淹没,
所以海平面上的格子会露出;崖壁是径向直墙,没有悬垂或侵蚀细节;球面生成器没有河流、
侵蚀与板块构造——平面的 `HexMapGenerator` 没有移植过来。球面地图也**没有存档格式**。
+8
View File
@@ -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()`、
+30
View File
@@ -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 原有纹理。
+13
View File
@@ -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()` 可用于存档外的运行诊断。
## 使用要点
- 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。
File diff suppressed because it is too large. Load diff
+16
View File
@@ -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。
+14
View File
@@ -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。
+196
View File
@@ -145,6 +145,202 @@ if (world.isObjectConsumed("village", "old_chest")) { /* 不再生成宝箱 */ }
缺失括号、尾随文本、非法 token 与非有限数值会拒绝注册;C++
`evaluateFormulaChecked()` 在运行时除零返回结构化失败,兼容求值入口投影为 `0`。
## `.dnut` 剧情脚本(RPG 方言)
`.dnut` 是引擎的作者序列语言。**RPG 方言**由 `rpg` 模块注册词表,用一份文本描述
剧情事件的移动、动画、选取、技能、属性、物品与装备。语言核心(词法、编译、
跨帧解释器)在 L1 模块 `dnut_interpreter`,RPG 只贡献**词表**与领域 handler。
### 最小例子
```
story village.arrival {
move actor=hero x=10 y=4 duration=0.5
animation target=hero clip=emote.wave hold=true
skill actor=hero learn=fireball
attribute actor=hero name=attack op=add value=5
item add=potion count=2
equipment actor=hero equip=iron-sword slot=weapon
flag name=village.visited state=true
message text=Arrived
}
```
### 步骤词表
| 步骤 | 字段 | 形状 | 谁执行 |
| --- | --- | --- | --- |
| `move` | `actor` `x` `y` `[duration]` | 宿主呈现 / 移动控制器 | `RpgStoryBinding::moveActor`;未装控制器时由呈现层执行,脚本确认 |
| `animation` | `target` `clip` `[loop]` `[hold]` | 宿主呈现 / 播放控制器 | `RpgStoryBinding::playAnimation`;未装控制器时由呈现层执行,脚本确认 |
| `select` | `object` `[prompt]` | 宿主呈现 | 交互层 |
| `camera` | `x` `y` `[duration]` | 宿主呈现 | 相机 |
| `dialogue` | `id` | 宿主呈现 | dialogue 模块 |
| `message` | `text` | 宿主呈现 | UI |
| `skill` | `actor` + (`learn` \| `forget`) | 立即 | `RPGActor::learnSkill` / `forgetSkill` |
| `attribute` | `actor` `name` `op(set\|add\|sub\|mul)` `value` | 立即 | `RPGActor` 基础属性 |
| `item` | (`add` \| `remove`) `[count=1]` | 立即 | `inventory::Bag` |
| `equipment` | `actor` + (`equip` `slot` \| `unequip`) | 立即 | `inventory::EquipmentSet` |
| `flag` | `name` `state` | 立即 | `GameState` 开关 |
| `variable` | `name` `op(set\|add)` `value` | 立即 | `GameState` 变量 |
控制流:`if <条件> { … } else { … }`、`choice { option "标签" [when <条件>] { … } }`、
`call <storyId>`、`wait <秒>`、`end`。
条件是 `变量 运算符 字面量`,可用 `&&`、`||`、`!` 和括号组合。变量名用点号寻址
`GameState`:`switch.<名>`、`variable.<名>`、`self.<scope>.<名>`。
### 角色移动(跨游戏模式)
`.dnut` 只声明「谁去哪里、用多久」,**不声明怎么走**。`x` / `y` 的含义由宿主定义:
格子 RPG 当作格坐标,战棋当作棋盘格,连续世界当作世界单位,横版当作车道。引擎
既不解释也不取整,更不假设任何寻路模型,所以同一份剧情脚本可以同时服务不同玩法。
宿主在 binding 上装一个 `moveActor`,`move` 步骤就自动交给它,不必再在宿主循环里
写 `if (kind == "move")` 分支:
```cpp
eve::rpg::RpgStoryBinding binding;
binding.party = &party;
binding.gameState = &gameState;
binding.moveActor = [](const eve::rpg::StoryMoveRequest& request) {
// 你的移动模型:格子 / 战棋 / 连续世界都在这里决定。
return navigator.request(request.actorId, request.x, request.y,
request.hasDuration, request.duration);
};
```
三种返回值决定剧情如何继续:
| 返回值 | 效果 |
| --- | --- |
| `StoryMoveStatus::Arrived` | 步骤**立即完成**,剧情继续,不挂起(瞬移,或角色已在目标点) |
| `StoryMoveStatus::Travelling` | 剧情挂起,宿主在角色**真正到达后**调用 `session.advance()` |
| `StoryMoveStatus::Unreachable` | 步骤失败,返回结构化诊断,游标停在原地 |
控制器返回失败 `Result` 时诊断原样上报,因此「寻路服务没加载」这类原因不会被吞掉。
**不装控制器时行为完全不变**:`move` 仍是宿主呈现步骤,脚本读 payload 后 `advance()`。
这是给「移动必须走自己的命令队列」这类宿主留的出口,也是既有内容的兼容路径。
`StoryMoveRequest::actor` 只是便利投影:仅当 binding 能解析该 id(`party` 或
`resolveActor`)时才非空。地图物件、相机、编队等非队伍主体会让它为 null,而步骤
照样执行——用 `actorId` 在自己的主体空间里解析即可。
### 动画播放(跨游戏模式)
`.dnut` 只声明「谁播哪个 clip、要不要循环、结束后是否定格」,**不声明怎么播**。
`clip` 对引擎是**不透明字符串**:骨骼动画 clip id、Sprite 序列名、Spine 动画、
montage 段都可以。引擎既不解析也不加载它,更不假设任何骨骼或动画栈,所以同一份
剧情脚本可以同时服务不同玩法。
宿主在 binding 上装一个 `playAnimation`,`animation` 步骤就自动交给它,不必再在
宿主循环里写 `if (kind == "animation")` 分支:
```cpp
eve::rpg::RpgStoryBinding binding;
binding.party = &party;
binding.gameState = &gameState;
binding.playAnimation = [](const eve::rpg::StoryAnimationRequest& request) {
// 你的播放模型:骨骼 / Sprite / Spine / montage 都在这里决定。
return player.play(request.targetId, request.clip, request.loop, request.hold);
};
```
三种返回值决定剧情如何继续:
| 返回值 | 效果 |
| --- | --- |
| `StoryAnimationStatus::Finished` | 步骤**立即完成**,剧情继续,不挂起(瞬发、零长 clip、纯表现性闪烁) |
| `StoryAnimationStatus::Playing` | 剧情挂起,宿主在 clip **真正播完后**调用 `session.advance()` |
| `StoryAnimationStatus::Unavailable` | 步骤失败,返回「谁播不了哪个 clip」的结构化诊断,游标停在原地 |
控制器返回失败 `Result` 时诊断原样上报,因此「clip 库还在流式加载」这类原因不会被吞掉。
`loop` 与 `hold` 按作者书写的原样透传,引擎不解释:`loop` 表示无限重复,`hold` 表示
clip 结束后停在最后一帧。宿主如果没有对应概念,忽略自己没实现的标志即可——省略这两个
字段时它们都是 `false`,不会替宿主做任何猜测。
**不装控制器时行为完全不变**:`animation` 仍是宿主呈现步骤,脚本读 payload 后
`advance()`。这是给「播放必须走自己的状态机 / 混合调度」这类宿主留的出口。
`StoryAnimationRequest::actor` 与移动的 `actor` 一样只是便利投影:仅当 binding 能解析
该 id(`party` 或 `resolveActor`)时才非空。特效挂点、地图道具、UI 元素等非队伍主体会让
它为 null,而步骤照样执行——用 `targetId` 在自己的主体空间里解析即可。
### 使用
下面的脚本走的是**未装 C++ 移动控制器**的宿主呈现路径:`move` 怎么做完全由脚本
决定,这正是「脚本控制角色移动」的推荐写法,也是跨玩法最灵活的一条路。
```squirrel
local rpg = getModInst("rpg");
rpg.replaceStoriesFromDnut(fileContent, "village.dnut"); // 严格校验 + 原子替换
local session = rpg.newStorySession();
local result = session.begin("village.arrival", gs, party, bag, equipment);
if (!result.ok) { print(result.status.summary + "\n"); return; }
while (session.isActive()) {
local kind = session.getStepKind();
if (kind == "move") {
// 呈现层负责移动;完成后确认
moveActor(session.getStepString("actor"), session.getStepNumber("x"), session.getStepNumber("y"));
session.advance();
} else if (kind == "choice") {
session.select(session.getChoiceLabel(0));
} else if (kind == "message") {
showMessage(session.getStepString("text"));
session.advance();
} else {
session.advance();
}
}
```
### 脚本面(Squirrel)
`eve.rpg` 上的方法:
| 方法 | 作用 |
| --- | --- |
| `replaceStoriesFromDnut` | 严格编译并原子替换 `.dnut` 故事目录 |
| `clearStories` | 清空已发布的故事 |
| `getStoryCount` | 已发布故事数量 |
| `hasStory` | 某个 story id 是否存在 |
| `newStorySession` | 新建一个 `RPGStorySession` |
`RPGStorySession` 上的方法:
| 方法 | 作用 |
| --- | --- |
| `begin` | 启动一个 story,运行到第一个挂起点;`party` / `bag` / `equipment` 可传 `null`(纯演出 story 不需要它们) |
| `advance` | 确认当前宿主呈现的步骤并继续 |
| `select` | 按标签回答 `choice` 步骤 |
| `stop` | 停止并清空会话 |
| `isActive` | 是否有 story 正在运行 |
| `isBlocked` | 是否挂起等待确认 |
| `getStoryId` | 当前 story id |
| `getStepKind` | 当前步骤类型 |
| `getChoiceLabelCount` | 当前 `choice` 的选项数 |
| `getChoiceLabel` | 按序号取选项标签 |
| `getStepString` | 取当前步骤的字符串字段 |
| `getStepNumber` | 取当前步骤的数值字段 |
| `getStepBool` | 取当前步骤的布尔字段 |
### 语义要点
- **严格编译**:字段名、字段类型、跨字段规则(`learn` 与 `forget` 恰有其一)在
`replaceStoriesFromDnut` 时报错;含错误的文档**不替换**上一份目录。
- **宿主呈现的步骤**没有 C++ handler:运行时会挂起,脚本必须 `advance()`;
`choice` 必须用 `select(标签)` 回答,`advance()` 会被拒绝。
`move` 在装了移动控制器时由 handler 派发,但**确认方式相同**——角色到达后
调用 `advance()`;控制器返回 `Arrived` 时根本不会挂起。
- **领域步骤**在运行时立即执行;失败(例如袋中不足)会让 `begin`/`advance` 返回
结构化失败并保持游标,不会静默跳过。
- **完成事实**:不可重复的 story 完成后把 `story.<id>.completed` 写进 `GameState`,
再次 `begin` 会失败;`repeatable` story 每次重新开始。
- **快照**:`begin` 时快照整份目录,热替换不会让活动会话悬空。
## 常见问题
- 只注册 Skill 未注册其引用的 Effect。
+41
View File
@@ -191,6 +191,8 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
- `getNodeCount()`、`getNodePath()`、`getParentId()`、`getRootId()`、`hasNode()`、`isAncestor()`、`isDescendant()`、`linkRenderable2D()`
- `linkRenderable3D()`、`mountBuild()`、`mountBuildAs()`、`remountBuildAs()`、`select()`、`setBuildPosition()`、`setBuildRotation()`、`setBuildScale()`
- `setBuildSpace()`、`setBuildVisible()`、`setHostLayer()`、`setHostVisible()`、`setNodePosition()`、`setNodeRotation()`、`setNodeScale()`、`setNodeVisible()`
Pcg `Growth` 由 `eve.PcgGrowth()` 承接。`configure(startScale,endScale,variance,growthTime)` 配置范围,`start(realtime,seed)` 选择确定性的实际终点;`advance(realtime)` 返回统一缩放,`applyPcgGrowth(growth,node,realtime)` 将其应用到真实 `SceneNodeRef`,并在 `die(realtime)` 后五秒删除节点。状态可用 `getActualEndScale()`、`getScale()`、`getFinished()`、`shouldDestroy(realtime)` 查询。
- `unlinkNode()`、`updateTransforms()`、`updateTransformsAll()`、`walkBreadthFirstIds()`、`walkDepthFirstIds()`
辅助对象:
@@ -198,6 +200,22 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
- `eve.SceneEntity`:`scene()` / `node()` / `onAttach()` / `onDetach()` / `update(dt)`,组件槽字段与 `eve.Entity` 一致。
- `eve.SceneNodeRef`(节点句柄):`getNodeId()`、`getPersistentId()`、`getHostName()`、`isValid()`、`getScene()`、`setPosition()` / `getPosition()`、`setRotation()` / `getRotation()`、`setScale()` / `getScale()`、`setVisible()` / `isVisible()`、`getWorldPosition*()`、`getWorldMatrix()`、`getForward()/getRight()/getUp()`、`getParentId()`、`getChildCount()`、`getChildIdAt()`、`getPath()`、`attachEntity()`、`detachEntity()`、`getEntity()`、`hasEntity()`、`entitiesOf()`、`linkRenderable2D/3D()`、`linkPhysics2D/3D()`、`linkCamera3D()`、`linkAudio3D()`、`unlinkNode()`、`unlinkNodeKind()`、`linkCount()`、`localToWorld()`、`worldToLocal()`、`setParent()`、`removeNode()`、`setQuaternion()`、`getQuaternion()`、`lookAt()`、`addTag()`、`removeTag()`、`hasTag()`、`getTags()`、`setLayer()`、`getLayer()`、`setBounds()`、`hasBounds()`、`getBounds()`。
## 跟随玩家的环境对象
`FollowPlayerSettings`、`FollowPlayerInput`、`FollowPlayerOutput` 与
`evaluateFollowPlayer(output,settings,input)` 对应 Pcg `FollowPlayerSystem`。在 LateUpdate 阶段填入玩家世界
坐标,将 `applyPosition=true` 的输出位置应用到每个调用者拥有的粒子、水体或场景节点。关闭 offset 时完全
复制玩家坐标;普通 offset 使用 `(playerX+offsetX, playerY-offsetY, playerZ-offsetZ)`;water 模式在玩家
Y<1 时额外抬高 70,否则抬高 10,再减 offsetY。`useScale` 独立发布一次性 scale 写入语义。
计算层不查找全局 Camera、不持有对象列表,也不直接依赖 graphics、water 或 particles provider;调用者负责
稳定对象身份和实际变换写入。缺少玩家或关闭 follow 时不发布位置,缩放仍可发布。所有输入先验证有限性,
失败不会部分覆盖 output。
脚本字段:settings 提供 `followPlayer`、`waterObject`、`useOffset`、`offsetX/Y/Z`、`useScale`、
`scaleX/scaleY/scaleZ`;input 提供 `hasPlayer`、`playerX/Y/Z`;output 提供 `applyPosition`、`x/y/z`、
`applyScale` 与 `scaleX/scaleY/scaleZ`。
## 使用要点
- 模块对象和它创建的资源对象应保存在全局或实体状态中,不要在每帧重复创建。
@@ -206,3 +224,26 @@ for (local i = 0; i < octree.getResultCount(); ++i) {
**源码:** [`src/modules/scene/`](../../../src/modules/scene/)
**相关测试:** 在 [`test/`](../../../test/) 中搜索 `scene`。
### Pcg 位置与书签
`LocationProfile` 保存一次性的启动位置,并管理 Pcg `LocationSystem` 风格的命名书签。
`LocationPose` 以位置和四元数保存相机/玩家姿态;`LocationBookmark` 还保存控制器与场景名。
书签暴露 `name`、`controller`、`scene` 字段,以及 `setCamera`、`setPlayer`、`clearPlayer`。
脚本可调用 `saveLocation`、`saveLocationWithPlayer`、`loadLocation`、`addBookmark`、
`overrideBookmark`、`removeBookmark`、`loadBookmark`、`previousBookmark`、`nextBookmark`、
`clearBookmarks`、`getBookmarkCount`、`getBookmarkName`、`getBookmarkController`、
`getBookmarkScene` 和 `hasSavedLocation`。修改与读取操作返回标准 `Result`;加载启动位置只成功一次。
`serializeJson` 与 `restoreJson` 使用 `eve.scene.location-profile` schema version 1;恢复会拒绝未知字段,
并在任何解析或验证失败时保留原状态。
### PcgScenePlayer 地形视锥裁剪
`applyPcgTerrainCullingAt(host,camera,viewWidth,viewHeight,tag)` 检查指定 host 内带 tag 且具有 bounds 的
节点,并把视锥相交结果原子应用到节点 visibility;返回实际变化数量。当前 host 的简写为
`applyPcgTerrainCulling(camera,viewWidth,viewHeight,tag)`。未标记节点不受影响,场景加载器或 streaming
层可在组成变化后重新调用,替代 Pcg 在 sceneLoaded/sceneUnloaded 后 0.5 秒重新扫描 Unity Terrain 的路径。
### PcgBuildConfig
`eve.PcgBuildConfig()` 对应 Pcg ResourcesSystem 的 `BuildConfig`。发布类型为 Addressables=0、RegularBuild=1;`addHistory(category,sceneName,timestamp)` 按调用顺序记录七类构建事件,category 为 0..6。使用 `getHistoryCount/Category/SceneName/Timestamp` 查询,越界或非法枚举返回结构化错误。
脚本方法:`setPublicationType()`、`getPublicationType()`、`addHistory()`、`clearHistory()`、`getHistoryCount()`、`getHistoryCategory()`、`getHistorySceneName()`、`getHistoryTimestamp()`。
+14 -2
View File
@@ -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 使调度可回放,非法时间不会改变队列。
+107
View File
@@ -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()` 也使用这两个条件与场景名、渲染管线共同决定是否允许加载旧设置。
+61 -1
View File
@@ -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 启用时保存原状态,撤销或析构时恢复。
+117 -2
View File
@@ -9,6 +9,36 @@ make run/<platform>-debug GAME=examples/<name>
部分示例另有 Makefile 快捷入口:`make devlab` / `basic` / `ecs` / `rpg` / `inventory` / `building`。
新手建议顺序:`devlab` → `basic` → 你感兴趣的玩法示例 → 程序化生成示例。
## 示例规范(新增示例必读)
`python3 scripts/check_examples.py` 在 CI 的 `source-quality` 阶段强制目录契约,
`scripts/smoke_examples.sh` 在 Linux 阶段强制运行时契约:
1. **目录形式**:`examples/<name>/` 要么是可运行示例(`main.nut` + `config.nut`),要么是在
`scripts/check_examples.py` 的 `NON_RUNNABLE_EXAMPLES` 中登记了原因的非运行样例
(C++ 程序 / 插件 / 资产包)。新增目录两者都不满足时 CI 直接失败。
2. **`config.nut` 必须赋值 `config` 表**:引擎先给默认 `config`,再 `dofile("config.nut")`。
自造的 `window_title` / `window_width` 之类全局不会被读取,窗口会静默退回默认 800×600。
3. **每个示例目录都要有 `README.md`**:说明演示内容、运行命令、操作方式与相关文件。
4. **每个示例都要登记在本文件的表格中**:不允许出现指向不存在目录的死链接。
5. **至少运行 2 秒不报错**:`scripts/smoke_examples.sh` 逐个启动可运行示例,`MIN_RUN_SECONDS`
(默认 2 秒,CI 同为 2 秒;每个示例实际观察 `RUN_SECONDS`,默认 6 秒)之后进程必须
仍然存活,且日志中不得出现 `frame error`、
`Run failed:`、`eve_init failed`、`failed to load:` 等错误标记;提前退出或非零退出码都算失败。
新示例因此必须真的能启动并进入帧循环,不能靠“跳过”过 CI。
本地自查:
```bash
python3 scripts/check_examples.py # 目录 / 文档 / 登记契约
python3 scripts/check_examples.py --print-missing-rows # 打印缺失的登记行
MIN_RUN_SECONDS=2 RUN_SECONDS=6 bash scripts/smoke_examples.sh <name>
```
Windows 本地直接跑同样的命令即可:脚本自身按 UTF-8 写 stdout/stderr,`--print-missing-rows`
打印的中文标题不会因为控制台代码页是 GBK 而报错(约定见 `AGENTS.md` 与
`scripts/tests/test_utf8_stdio.py`)。
## 开发者体验
| 示例 | 演示能力 |
@@ -16,8 +46,8 @@ make run/<platform>-debug GAME=examples/<name>
| [devlab](devlab/README.md) | 运行时即编辑器:`--debug` 下的控制台 / REPL、快照、暂停、热重载、错误切片、AI 面板(**旗舰示例,先跑这个**) |
| [ai-game](ai-game/README.md) | AI 驾驶真实游戏:Agent 通过 MCP 读状态 / 改数值 / 截图 / 快照复位(含一键复现脚本 `agent_demo.py`) |
| [ai-editor](ai-editor/README.md) | AI 现场生成项目专属编辑器:`eve mcp` 无头主机 + JSON View + ViewModel 双向绑定(含 `editor_demo.py`) |
新增人物动画示例:[Character Motion Lab](character-motion-lab/README.md) — 三个人物、八种免费动作、跨骨架重定向、同步播放与逐帧观察。
| [inspector-demo](inspector-demo/README.md) | DevTools 属性检视器:自动扫描脚本类及其属性并渲染为可编辑面板(MVVM) |
| [editor-ui-gallery](editor-ui-gallery/README.md) | 可复用编辑器 UI 套件展示:图标字体、菜单栏、工具栏、搜索侧栏、分屏、卡片、表单控件与游戏内 HUD 共存 |
## 入门与基础
@@ -26,40 +56,64 @@ make run/<platform>-debug GAME=examples/<name>
| [basic](basic/README.md) | Tilemap、Box2D 物理、粒子、基础 2D 绘制;另含两个 UI 变体(`ui_demo.nut` / `ui_component.nut`) |
| [ecs](ecs/README.md) | 脚本 ECS(Component / Entity / System);`gpu_main.nut` 为 compute shader 变体 |
| [scene-ecs](scene-ecs/README.md) | 场景树与脚本 ECS 打通:SceneEntity 行为、eve.view 批量查询 |
| [primitive-drawing](primitive-drawing/README.md) | 长期 `Primitive3D` 脚本 API:2D / 3D 基础图形绘制 |
## 2D 玩法系统
| 示例 | 演示能力 |
|---|---|
| [rpg](rpg/README.md) | RPG 五系统:属性 / 效果 / 状态 / 技能 / 结算(最小可玩动作 RPG) |
| [rpg-classic](rpg-classic/README.md) | 经典回合制 RPG 纵向切片:`eve.RPG()` + `eve.Inventory()` + `map` 的数据驱动界面 |
| [inventory](inventory/README.md) | 背包:物品定义、主背包 / 任务栏 / 仓库、装备穿脱、跨包转移 |
| [cardgame](cardgame/README.md) | 可玩的双人德州扑克:四轮下注、AI、七选五牌型、真实烧牌与 52 张 CC0 牌面 |
| [dialogue](dialogue/README.md) | 对话 + Avatar:Squirrel generator 剧情、i18n 翻译表、分层立绘、程序化台词池(.dnut) |
| [galgame](galgame/README.md) | 完整视觉小说《潮汐电台》:原创美术、分支结局、AUTO/SKIP/回想与存读档 |
| [building](building/README.md) | 建筑放置:地形约束、道路邻接、鬼影预览、旋转拆除 |
| [building-tilemap](building-tilemap/README.md) | 在等距 / 六角 tilemap 上放置建筑(2D 精灵穿插) |
| [autotile-production](autotile-production/README.md) | 生产级 autotile:岸线 / 墙体 / 瀑布三层规则与动画帧(零美术资源) |
| [swappable-tilesets](swappable-tilesets/README.md) | RPG Maker 风格图集接入:可替换 tileset + 自动拼接 |
| [pixelworld](pixelworld/README.md) | Noita 风格的分块沙、水、油、火焰与蒸汽像素物质仿真 |
| [iso-grid-walk](iso-grid-walk/README.md) | 独立 2.5D PNG 经可插拔 pipeline 生成 TileSet,方格移动与 A* |
| [hd2d-riverside](hd2d-riverside/README.md) | HD2D 河畔验收:真实像素素材、正交地图与 16 种地面图块 |
| [dynamic-water-grid](dynamic-water-grid/README.md) | 双网格岸线、逐格守恒水量、等距连续水面 Shader 与素材替换契约 |
| [metroidvania](metroidvania/README.md) | 物理驱动的横版动作游戏:连击、蹬墙跳、空中冲刺、Boss |
| [commandery-rts](commandery-rts/README.md) | 将领行政 RTS:框选/编队移动、占领经济点、生产、军饷与叛乱 |
| [rts-sandbox](rts-sandbox/README.md) | RTS 端到端 Squirrel 组合剖面:命令、生产、编队与结算 |
| [hex-levels](hex-levels/README.md) | 六边形引擎功能测试关卡:寻路 / FOV / 光照 / 掉落 / WFC(31 关) |
| [map-fog](map-fog/README.md) | 大地图迷雾:`MapFog` 双层云 + mask(解锁 / 选中 / 溶解) |
| [i18n](i18n/README.md) | 本地化:翻译表、占位符、复数规则、热重载 |
| [sprite-animation-vfx](sprite-animation-vfx/README.md) | 2D 精灵动画与 VFX API 的端到端脚本验证 |
| [vehicle](vehicle/README.md) | 通用载具系统:`eve.Vehicle()` + `eve.Weapon()` 的 2D 顶视完整链路 |
| [crowd](crowd/README.md) | 群体行为:2000 个单位在带障碍流场中行军与平滑转向(Boids) |
| [composable-rebellion](composable-rebellion/README.md) | 可组合玩法:引擎只存事实、叛乱语义全在脚本(15 个 `eve.*` 模块协作) |
## 3D 玩法与镜头
| 示例 | 演示能力 |
|---|---|
| [camera-controllers](camera-controllers/README.md) | 第三人称相机:follow / orbit / topdown / firstperson / cinematic |
| [character-motion-lab](character-motion-lab/README.md) | 三个人物、八种免费动作、跨骨架重定向、同步播放与逐帧观察 |
| [layered-animation](layered-animation/README.md) | 真实 CC0 KayKit 动作叠加:三段动作单独播放 + `AnimLayerMixer`(走路基底 + 脊柱遮罩攻击覆盖 + 受击叠加) |
| [climbing-motion-matching](climbing-motion-matching/README.md) | 攀爬与动作匹配演示(从示例目录内直接运行) |
| [building-3d](building-3d/README.md) | 3D 地面放置:射线求交、网格吸附、鬼影与放置会话 |
| [climbing-playground](climbing-playground/README.md) | 攀爬与跑酷:真实物理探测、低/高跨越、翻越、空中抓边、阻挡与移动平台 |
| [daynight](daynight/README.md) | 昼夜循环:太阳轨道、程序化天空盒、月光 / 星光 / 火焰 / 萤火虫 |
| [weather](weather/README.md) | 天气系统:雨 / 雪 / 雷暴 / 雾 / 风,实时滑块 |
| [weather-daynight](weather-daynight/README.md) | 统一昼夜 + 天气:太阳轨道、程序化天空与雨雪雷暴 |
| [snow](snow/README.md) | 可交互积雪:与高度图同尺寸的 `SnowField` 深度场驱动形变与着色 |
| [softbody](softbody/README.md) | 布料与 2D 流体解算器(拖拽 / 排斥 / 吸引) |
| [softbody3d](softbody3d/README.md) | 3D 软体:Verlet 布料 + 体积体与静态 Box3D 碰撞 |
| [lattice-deform](lattice-deform/README.md) | 3D 晶格缩放变形:squash & stretch、局部鼓起、波浪 |
| [sprite-stack](sprite-stack/README.md) | 伪 3D:把 3D 模型切成多层 RGBA 叠片渲染 |
| [armored-command-3d](armored-command-3d/README.md) | 3D RTS 展示:骨骼坦克、编队命令、齐射与指挥所坍塌 |
| [tactics](tactics/README.md) | 固定镜头 3D 战棋:4 名冒险者对战 6 种骷髅敌人(12×5 方格) |
| [venom-transform](venom-transform/README.md) | 毒液变装效果近似:形变与材质过渡 |
| [anime-character-lab](anime-character-lab/README.md) | anime 风格化着色检查:`stylize.newMeshShader(gfx, "anime")` |
| [avatar-document-editor](avatar-document-editor/README.md) | Azure / Avatar 工作区:可编辑分层母稿 + 真实 `eve.Avatar` 图层渲染 |
| [vrm-avatar](vrm-avatar/README.md) | VRM 角色加载与渲染示例 |
| [voxel](voxel/README.md) | 流式体素地形 + 方块建造:脚本图集、流式 chunk 与 DDA 拾取 |
| [voxel-terrain](voxel-terrain/README.md) | 体素引擎 + 地形生成:32³ chunk、贪婪矩形合并、视锥/视距裁剪、顶点 AO |
| [fluid3d](fluid3d/README.md) | 体积流体实验室:19 个场景模式(水 / 黏性 / 烟雾 / 颗粒 / 射流 / 网格化 / 多相 / 热耦合 / 泡沫 / 移动 SDF / 重叠查询 / 尾流…)+ 70 余个 opt-in 数值验收脚本 |
## 程序化生成
@@ -67,15 +121,41 @@ make run/<platform>-debug GAME=examples/<name>
|---|---|
| [procgen](procgen/README.md) | 六种地图算法(BSP / Cellular / Drunkard / Maze / 地形 / WFC)+ 纹理配方 |
| [procgen-script-pipeline](procgen-script-pipeline/README.md) | 纯脚本 PointSet 组合、确定性 seed、事务式 hot reload |
| [tileworld-graph-dungeon](tileworld-graph-dungeon/README.md) | TileWorldCreator 风格 GridGraph + PointGraph + MeshGraph 混合地牢、分层构建、增量簇更新与可选物理碰撞 |
| [pcg-biome](pcg-biome/README.md) | UE PCG 风格空间数据、多层运行时 Cell、时间预算与 Scene 实例批次 |
| [pcg-runtime-orchestration](pcg-runtime-orchestration/README.md) | Pcg 运行时编排的数值契约:运行时盖章器 + 生成进度 + 任务队列(无 GPU 场景,等价脚本级单测) |
| [pcg-location-system](pcg-location-system/README.md) | 位置书签:相机位姿 + 玩家位姿 + 控制器 + 场景名的命名书签存取与取景 |
| [pcg-layer-culling](pcg-layer-culling/README.md) | 按渲染层设置相机与阴影剔除距离(layer 8:相机 32 / 阴影 26),远处同层塔必须被剔除 |
| [pcg-scene-player-culling](pcg-scene-player-culling/README.md) | PcgScenePlayer 地形剔除:包围盒 + `terrain` 标签驱动节点可见性,无标签节点保持可见 |
| [pcg-depth-of-field](pcg-depth-of-field/README.md) | Pcg 自动景深对焦:视线命中距离驱动对焦状态机,输出写回相机景深(GBuffer 合成路径) |
| [pcg-detail-overwrite](pcg-detail-overwrite/README.md) | 地形细节覆盖:PointSet 草地点 + 程序化 albedo/normal/mask 烘焙植被,再覆盖细节距离 / 密度 / 每 patch 分辨率 |
| [pcg-snow-wind](pcg-snow-wind/README.md) | 天气雪的世界空间风场:三分量写入与读回校验(非相机跟随偏移) |
| [pcg-thunder-strike](pcg-thunder-strike/README.md) | 雷击事件:确定性 seed 决定落点 / 半径 / 音频片段索引,并逐帧驱动点光源衰减 |
| [pcg-ui-scaler](pcg-ui-scaler/README.md) | UI 父级缩放:按画布高度自适应并夹紧到上限,求值结果直接落地成面板 |
| [pcg-underwater](pcg-underwater/README.md) | 水下效果全链路:深度雾与颜色梯度、时间驱动后期、水焦散 cookie、水下材质、三路音频、粒子与触发体 |
| [roguelike-generator](roguelike-generator/README.md) | 种子驱动的房间走廊地牢:autotile、装饰、2D ↔ 2.5D |
| [dungeon-generator-3d](dungeon-generator-3d/README.md) | `procgen.generate("level.roguelike", ...)` 的 3D 可配置呈现 |
| [tree-generator](tree-generator/README.md) | 确定性树木:Weber-Penn / 空间殖民两种骨架算法 |
| [bush-generator](bush-generator/README.md) | 低多边形灌木配方(mesh.bush)与参数实时调节 |
| [bush-fog-volumes](bush-fog-volumes/README.md) | 程序化灌木穿过全局与局部体积雾的深度、透明度和多视角检查 |
| [rock-generator](rock-generator/README.md) | 岩石配方:变形 + 程序化石材纹理 + 自动 LOD |
| [castle-generator](castle-generator/README.md) | 城堡配方 `mesh.castle`:同心结构、塔楼与程序化材质 |
| [cave-generator](cave-generator/README.md) | 喀斯特洞穴配方 `mesh.cave`:确定性生成与形态参数 |
| [skyscraper-generator](skyscraper-generator/README.md) | 退台塔楼配方:窗格、尖顶、程序化立面纹理 |
| [urban-generator](urban-generator/README.md) | 城市配方 `mesh.urban` / `urban.parcels`:街区与地块 |
| [housegen](housegen/README.md) | 房屋布局生成 + GLB kit 实例化 |
| [linear-structures](linear-structures/README.md) | 线性可拼接结构:栅栏 / 石墙 / 桥 / 长城 / 树篱 / 拒马 |
| [hex-terrain](hex-terrain/README.md) | 无源美术的 3D 六边形世界:`mesh.hexterrain` 配方 |
| [hex-terrain-3d](hex-terrain-3d/README.md) | 可交互 3D 六边形地图:高程台地与悬崖、水面、河流、道路、城墙与地物、战争迷雾、单位寻路、程序化地图生成、存档、相机操控与笔刷编辑 |
| [hex-planet](hex-planet/README.md) | 球面 hex 地形:二十面体(Goldberg)六边形拓扑、径向高程与台地/悬崖、程序化大陆与海洋、轨道相机(`hexmap` 球面后端) |
| [terrain-preview](terrain-preview/README.md) | 直写交换链的 3D 地形预览:三种侵蚀 + 河湖水面 + 自动抓帧 |
| [terrain-gallery](terrain-gallery/README.md) | 三个固定 seed 在相同生成参数、光照与材质下的并排对比 |
| [mesh-modifier-lab](mesh-modifier-lab/README.md) | 13 种网格变形变体并排对照:类型化修饰图(bend / twist / FFD / 切面 / 样条 / 声波)、雕刻笔刷、粘液回弹、Mesh Fit、顶点编辑器 |
| [spline-tube-lab](spline-tube-lab/README.md) | 无源网格生成:样条路径驱动管道 / 带状体 / 自定义截面挤出,含开放与闭合回路、分块、分布采样与行进帧 |
| [procedural-road](procedural-road/README.md) | 程序化多层立交:截面挤出道路/桥墩/标线/导航逻辑叠加(`mesh.roadNetwork`) |
| [mesh-impact-lab](mesh-impact-lab/README.md) | Box3D 命中事件驱动网格塑性冲击:法向冲量 → 形变会话 → 重新上传 |
| [geometry-stroke-lab](geometry-stroke-lab/README.md) | 几何笔刷轨迹:quad / 三棱柱 / 立方体截面、平面与空间输入、最小间距过滤、撤销、CPU 生成 + GPU 上传 |
| [terrain-stamping](terrain-stamping/README.md) | 地形盖章:旋转印章、距离遮罩、对比度、阶地、平滑与热力核,含 Pcg GTS 雪 / 雨 albedo 分支 |
## 渲染与效果
@@ -83,20 +163,55 @@ make run/<platform>-debug GAME=examples/<name>
|---|---|
| [outline](outline/README.md) | 屏幕空间描边(G-buffer 深度 + 法线) |
| [waterfall-demo](waterfall-demo/README.md) | 瀑布流动着色器(条纹 / 湍流 / 泡沫) |
| [silpom-ssdm-compare](silpom-ssdm-compare/README.md) | 平面 SilPOM vs SSDM:硬砖 heightfield、轮廓挤出与 FragDepth 取舍对比 |
| [virtualgeometry](virtualgeometry/README.md) | 虚拟几何体:cluster DAG + GPU 剔除 + 软件光栅化(Vulkan/WebGPU) |
| [atmospheric-fog](atmospheric-fog/README.md) | 大气 froxel 体积雾:最小的完整渲染路径与参数调试 |
| [particle-playback-lab](particle-playback-lab/README.md) | 粒子回放实验室:确定性播放、版本化多发射器与视觉验证 |
| [rendering-chain-lab](rendering-chain-lab/README.md) | 渲染链运行时对比:TAA / SSR / RTGI 与自动反射链开关(Space / R) |
| [shader-live-preview](shader-live-preview/README.md) | 实时 GLSL 预览:编辑 `shaders/preview.frag` 保存即热重载 |
| [virtual-texture-blending](virtual-texture-blending/README.md) | 虚拟纹理材质混合:常驻虚拟页 + fallback 槽与无缝 gutter |
| [ink-arena](ink-arena/README.md) | GPU 表面喷墨:片元着色器向持久 RGBA8 画布绘制 |
| [uv-texture-paint](uv-texture-paint/README.md) | 点击网格经 Box3D 三角形命中映射回模型 UV 并绘制贴图 |
| [tensor](tensor/README.md) | 张量编译管线:策略网络 / conv / SDPA / 批量模拟(纯计算) |
| [interior-mapping](interior-mapping/README.md) | 无室内几何的室内映射:预投影 Atlas、切线空间射线–AABB、伪透视缩放、过程化家具遮挡与掠射菲涅尔 |
| [triplanar-decal](triplanar-decal/README.md) | 三维投射贴花:局部空间三平面混合解决单轴投射在立面上的拉伸,含混合锐度对照 |
## 工具与扩展
| 示例 | 演示能力 |
|---|---|
| [composable-editor](composable-editor/README.md) | 可组合编辑器 SDK:C++ Workspace、动态面板、MVVM、ECS,以及游戏/编辑器共享运行时 |
| [scene-editor](scene-editor/README.md) | 可组合场景编辑:场景树、选择与属性联动 |
| [scene-builder-game](scene-builder-game/README.md) | 游戏内嵌场景建造:运行时可组合的场景编辑会话 |
| [level-designer](level-designer/README.md) | 3D 关卡设计工具:引用地形 → 放置白盒 → 调模型 → 出生点 → Motion Matching 试玩 |
| [terrain-editor](terrain-editor/README.md) | 运行时生成的地形编辑器:Viewport 内嵌 3D、笔刷、轨道相机 |
| [voxel-catalog-editor](voxel-catalog-editor/README.md) | MagicaVoxel 风格体素雕刻:占用网格与撤销 |
| [editor-api-v2](editor-api-v2/README.md) | Editor API V2 同构演示:游戏注入命令,玩家通过 discovery → plan → execute 建造场景 |
| [animation-clip-editor](animation-clip-editor/README.md) | 动画剪辑编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
| [audio-source-editor](audio-source-editor/README.md) | 音频源编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
| [biome-rules-editor](biome-rules-editor/README.md) | 生物群系规则编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
| [combat-action-editor](combat-action-editor/README.md) | 战斗动作编辑示例:用 UI 无关编辑器 SDK 组装项目专属工作区 |
| [procgen-script-editor](procgen-script-editor/README.md) | 宿主封装 Squirrel generator(`generators/forest.nut`)的程序化生成编辑器 |
| [ui-theme-editor](ui-theme-editor/README.md) | UI 主题编辑示例:组装命名 Theme 工作区 + 实时预览 |
| [material-editor](material-editor/README.md) | 材质编辑器:中央 UE5 材质球预览 + 右侧 Shading/Surface/Lighting 参数面板 |
| [ai-stage](ai-stage/README.md) | AI 空舞台:scene_director 搭台 kit + MCP,供 Agent 摆物 / 调光 / 截图 / 质检 |
| [blender_hot_reload](blender_hot_reload/README.md) | Blender 保存 → GLB → `SceneLoader.load` → 卸载旧 SceneHost 的热重载往返 |
| [model-converter](model-converter/README.md) | 驱动 Blender 把 OBJ 转换成 GLB 并回载(modelconverter 插件) |
| [archspace-editor](archspace-editor/README.md) | 户型文档烘焙:房间 / 走廊 / 门 / 窗 / 家具 → 带开洞的渲染器中立网格,经 `gfx.newMeshFromArrays` 上传 |
## 插件与脚本包(不是 eve run 项目)
这些目录由 CMake、`eve run -r` 或其它工具单独驱动,不参与 `smoke_examples.sh` 的帧循环契约;
`scripts/check_examples.py` 的 `NON_RUNNABLE_EXAMPLES` 是它们的唯一登记处。
| 示例 | 演示能力 |
|---|---|
| [native-plugin](native-plugin/README.md) | 原生插件:用 SDK 编译动态库,脚本直接调用 |
| [live2d-backend-plugin](live2d-backend-plugin/README.md) | Live2D 后端替换骨架:不改游戏脚本换 Cubism 运行时 |
| [agent](agent/README.md) | Squirrel 强化学习:定义环境、训练策略、选择动作与回放(C++ 侧 `eve.Agent`) |
| [economy](economy/README.md) | `eve.Economy` 无窗口脚本演示:采集循环与满仓浪费(`eve run -r`) |
| [shader_effect_package](shader_effect_package/README.md) | 打包式 shader effect 资产包(`effect.vert` / `effect.frag` + `parameters.json`) |
| [surface-fluid-dynamic](surface-fluid-dynamic/README.md) | C++ 侧表面流体参考实现(确定性 CPU 解算) |
## 与设计目标对照
+56
View File
@@ -0,0 +1,56 @@
# Autotile Production — 岸线、墙体与瀑布的自动拼接生产示例
本示例在不使用任何美术资源的前提下,用 `map.newLayer` 建立三层瓦片层,分别演示岸线、
墙体与瀑布三类自动拼接(autotile family)语义。它同时覆盖邻接掩码规则登记、矩形批量
绘制与擦除、以及循环瓦片动画,可作为自动拼接落地到关卡数据前的最小生产参考。
## 运行
```bash
make run/<platform>-debug GAME=examples/autotile-production
```
也可以进入示例目录后直接启动引擎:
```bash
cd examples/autotile-production && ../../build/linux-debug/src/engine/eve run
```
Windows 下引擎可执行文件为 `build/win32-debug/src/engine/eve.exe`。
## 演示内容
- 初始化:`eve_init` 先调 `gfx.setBackgroundColor(0.04, 0.06, 0.09, 1.0)`,再在
`map == null` 时创建 `eve.Map()`;三层分别由 `persist` 变量 `shore`、`walls`、
`falls` 持有,已存在时不会重复建立。
- 岸线层:`map.newLayer(14, 10, 32.0, 32.0)` 建出 14×10、32 像素瓦片的层并
`setOrigin(32.0, 48.0)` 定位;`defineAutotileFamily(1, "shore", 1337)` 注册地形 1,
随后用 `mask` 从 0 到 255 的循环为全部邻接掩码各登记一条规则
`setAutotileRule(1 + mask % 12, 1, mask, 1)`(gid 在 1–12 之间循环复用)。
- 矩形批量编辑:`paintTerrainRect(1, 1, 9, 7, 1)` 一次铺满整块地形,再用
`eraseTerrainRect(7, 1, 3, 2)` 与 `eraseTerrainRect(1, 6, 2, 2)` 各挖一个缺口,
让岸线掩码随邻接关系变化。
- 墙体层:`map.newLayer(8, 10, 32.0, 32.0)`、`setOrigin(520.0, 48.0)` 与
`defineAutotileFamily(2, "wall", 7)` 之后,按掩码分别绑定顶部(gid 21 / 掩码 32)、
墙体主体(gid 22 / 掩码 34)与墙脚(gid 23 / 掩码 2),两段 `paintTerrainRect`
画出高度不同的两堵墙。
- 瀑布层:`map.newLayer(6, 10, 32.0, 32.0)`、`setOrigin(720.0, 48.0)` 与
`defineAutotileFamily(3, "waterfall", 11)` 绑定水唇(掩码 32)、循环主体(掩码 34)
和溅落 / 墙脚(掩码 2);再用 `addTileAnimationFrame(32, 32, 140)`、
`addTileAnimationFrame(32, 34, 140)`、`addTileAnimationFrame(32, 35, 140)` 让主体
瓦片以 140 毫秒换帧循环播放。
- 每帧驱动:`eve_update(dt)` 调用 `map.update(dt)` 推进瓦片动画,`eve_render` 依次
调用 `gfx.clear()` 与 `map.render(gfx)` 重绘三层。
## 操作
`main.nut` 不轮询任何键盘或鼠标输入,示例里没有任何按键或鼠标绑定:三层地形与全部
自动拼接规则都在 `eve_init` 中一次性建立,运行时只按帧推进动画与重绘。
## 相关文件
- `main.nut`:示例全部逻辑——三层创建、family 与邻接掩码规则登记、矩形绘制 / 擦除、
瓦片动画,以及 `eve_update` / `eve_render` 的每帧更新与渲染。
- `config.nut`:窗口配置,标题为 `EVEngine Autotile Production`,尺寸 960×540,
`debug = false`。
- `README.md`:本文件。
+1 -1
View File
@@ -1,4 +1,4 @@
print("EVEngine v0.5.1")
print("EVEngine v0.5.2")
print("-----------------------------------------")
@@ -0,0 +1 @@
{"content":{"actionId":"combat:follow-up-kaykit","animationSections":[{"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","blendCurve":"ease-in-out","blendInNs":0,"endNs":320000032,"id":"kaykit-section:anticipation","sourceEndNs":0,"sourceStartNs":0,"startNs":0},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop","blendCurve":"ease-in-out","blendInNs":64000004,"endNs":661333376,"id":"kaykit-section:strike","sourceEndNs":0,"sourceStartNs":0,"startNs":320000032},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block","blendCurve":"ease-in-out","blendInNs":53333340,"endNs":1066666752,"id":"kaykit-section:recovery","sourceEndNs":0,"sourceStartNs":0,"startNs":661333376}],"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","durationNs":1066666752,"metadata":{"character":"asset://kaykit/Knight.glb","sourceLicense":"CC0-1.0","variant":"follow-up","weapon":"asset://kaykit/sword_1handed.gltf"},"montage":{"animationLayer":0,"basePlayRate":1,"blendOutOffsetNs":0,"defaultBlendInNs":120000000,"defaultBlendOutNs":150000000,"footIk":false,"looping":false,"rootMotionHorizontal":true,"rootMotionRotation":true,"rootMotionVertical":false},"schema":"eve.action.timeline","schemaVersion":3,"splitTimestampsNs":[320000032,661333376],"tracks":[{"id":"kaykit-track:gameplay","kind":"gameplay","label":"Gameplay","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:damage","payload":{"amount":18,"damageType":"Damage.Physical.Slash"},"timeNs":490666688,"type":"combat:damage"}],"states":[{"endNs":661333376,"id":"kaykit-state:hitbox","payload":{"hitbox":"weapon.main"},"startNs":320000032,"type":"combat:hitbox-window"},{"endNs":917333440,"id":"kaykit-state:combo","payload":{"input":"Ability.Combat.Attack.Light"},"startNs":693333312,"type":"input:combo-window"}]},{"id":"kaykit-track:presentation","kind":"effect","label":"Presentation","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:swing-audio","payload":{"uri":"asset://audio/sword-whoosh"},"timeNs":341333344,"type":"presentation:audio"},{"id":"kaykit-notify:swing-vfx","payload":{"lifetimeSeconds":0.44999998807907104,"uri":"asset://vfx/sword-arc"},"timeNs":362666688,"type":"presentation:vfx"},{"id":"kaykit-notify:impact-camera","payload":{"cue":"combat:light-impact","durationSeconds":0.23999999463558197,"fovAmplitude":2,"positionAmplitude":0.079999998211860657,"rotationAmplitude":1.7999999523162842,"seed":47},"timeNs":490666688,"type":"presentation:camera"}],"states":[{"endNs":800000064,"id":"kaykit-state:volume-curve","payload":{"keys":[{"inTangent":0,"interpolation":"cubic","outTangent":2.2000000476837158,"time":0,"value":0.34999999403953552},{"inTangent":0,"interpolation":"cubic","outTangent":0,"time":0.34999999403953552,"value":1},{"inTangent":-0.80000001192092896,"interpolation":"linear","outTangent":0,"time":1,"value":0.55000001192092896}],"operation":"multiply","target":"audio:master-volume"},"startNs":213333360,"type":"presentation:parameter-curve"}]},{"id":"kaykit-track:movement","kind":"movement","label":"Root Motion","locked":false,"muted":false,"notifies":[],"states":[{"endNs":1066666752,"id":"kaykit-state:root-motion","payload":{"mode":"animation"},"startNs":0,"type":"movement:root-motion-window"}]}]},"contentHash":"9d06ede6d3ef561a","revision":1,"schemaVersion":1}
@@ -0,0 +1 @@
{"guid":"asset.kaykit.follow-up","schemaVersion":1,"type":"eve.action.timeline"}
@@ -0,0 +1 @@
{"content":{"actionId":"combat:light-attack-kaykit","animationSections":[{"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","blendCurve":"ease-in-out","blendInNs":0,"endNs":320000032,"id":"kaykit-section:anticipation","sourceEndNs":0,"sourceStartNs":0,"startNs":0},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_1H_Attack_Chop","blendCurve":"ease-in-out","blendInNs":64000004,"endNs":661333376,"id":"kaykit-section:strike","sourceEndNs":0,"sourceStartNs":0,"startNs":320000032},{"animationUri":"asset://kaykit/Rig_Medium_CombatMelee.glb#Melee_Block","blendCurve":"ease-in-out","blendInNs":53333340,"endNs":1066666752,"id":"kaykit-section:recovery","sourceEndNs":0,"sourceStartNs":0,"startNs":661333376}],"animationUri":"asset://kaykit/Rig_Medium_General.glb#Idle_A","durationNs":1066666752,"metadata":{"character":"asset://kaykit/Knight.glb","sourceLicense":"CC0-1.0","weapon":"asset://kaykit/sword_1handed.gltf"},"montage":{"animationLayer":0,"basePlayRate":1,"blendOutOffsetNs":0,"defaultBlendInNs":120000000,"defaultBlendOutNs":150000000,"footIk":false,"looping":false,"rootMotionHorizontal":true,"rootMotionRotation":true,"rootMotionVertical":false},"schema":"eve.action.timeline","schemaVersion":3,"splitTimestampsNs":[320000032,661333376],"tracks":[{"id":"kaykit-track:gameplay","kind":"gameplay","label":"Gameplay","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:damage","payload":{"amount":18,"damageType":"Damage.Physical.Slash"},"timeNs":490666688,"type":"combat:damage"}],"states":[{"endNs":661333376,"id":"kaykit-state:hitbox","payload":{"hitbox":"weapon.main"},"startNs":320000032,"type":"combat:hitbox-window"},{"endNs":917333440,"id":"kaykit-state:combo","payload":{"input":"Ability.Combat.Attack.Light"},"startNs":693333312,"type":"input:combo-window"}]},{"id":"kaykit-track:presentation","kind":"effect","label":"Presentation","locked":false,"muted":false,"notifies":[{"id":"kaykit-notify:swing-audio","payload":{"uri":"asset://audio/sword-whoosh"},"timeNs":341333344,"type":"presentation:audio"},{"id":"kaykit-notify:swing-vfx","payload":{"lifetimeSeconds":0.44999998807907104,"uri":"asset://vfx/sword-arc"},"timeNs":362666688,"type":"presentation:vfx"},{"id":"kaykit-notify:impact-camera","payload":{"cue":"combat:light-impact","durationSeconds":0.23999999463558197,"fovAmplitude":2,"positionAmplitude":0.079999998211860657,"rotationAmplitude":1.7999999523162842,"seed":47},"timeNs":490666688,"type":"presentation:camera"}],"states":[{"endNs":800000064,"id":"kaykit-state:volume-curve","payload":{"keys":[{"inTangent":0,"interpolation":"cubic","outTangent":2.2000000476837158,"time":0,"value":0.34999999403953552},{"inTangent":0,"interpolation":"cubic","outTangent":0,"time":0.34999999403953552,"value":1},{"inTangent":-0.80000001192092896,"interpolation":"linear","outTangent":0,"time":1,"value":0.55000001192092896}],"operation":"multiply","target":"audio:master-volume"},"startNs":213333360,"type":"presentation:parameter-curve"}]},{"id":"kaykit-track:movement","kind":"movement","label":"Root Motion","locked":false,"muted":false,"notifies":[],"states":[{"endNs":1066666752,"id":"kaykit-state:root-motion","payload":{"mode":"animation"},"startNs":0,"type":"movement:root-motion-window"}]}]},"contentHash":"b6ff257dc806bb14","revision":1,"schemaVersion":1}
@@ -0,0 +1 @@
{"guid":"asset.kaykit.light-attack","schemaVersion":1,"type":"eve.action.timeline"}
+88 -5
View File
@@ -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
File diff suppressed because it is too large. Load diff
+58
View File
@@ -0,0 +1,58 @@
# Composable Rebellion — 引擎只存事实、玩法语义全在脚本的叛乱示例
本示例演示"通用能力 + 项目语义"的分层:引擎侧的 `eve.*` 模块只保存中性的属性、
归属、授权、指令、事件等事实,完全不认识"忠诚""叛乱"这类领域概念。全部政治含义
写在 `simulation.nut` 里,由一次拖欠军饷的薪资事件推动将领带着基地与军队倒向边疆阵营。
## 运行
```bash
make run/<platform>-debug GAME=examples/composable-rebellion
```
也可以进入示例目录直接启动引擎:
```bash
cd examples/composable-rebellion && ../../build/linux-debug/src/engine/eve run
```
Windows 下可执行文件为 `build/win32-debug/src/engine/eve.exe`。
## 演示内容
- 模块装配:`reset_demo` 创建 `eve.Attributes`、`eve.Social`、`eve.Orders`、`eve.Tags`、`eve.Effects`、
`eve.GameEvent`、`eve.Transaction`、`eve.StatePatch`、`eve.Definitions`、`eve.Authority`、`eve.Production`、
`eve.PolicyRegistryModule`、`eve.Sensing`、`eve.Steering`、`eve.Decision`——引擎侧没有叛乱专用类型。
- 事实与授权:`setBase` 写入 `administration` 80、`loyalty` 48、`ambition` 82 与 `production_speed` 1.0;
`setOwner` / `assign` / `setRelation` 建立 `general.arden`、`base.north`、`army.first` 的归属和
`officer.vela` 对将领的 `support` 关系;`Authority.grant` 授予 `govern_base` / `command_army`,
`PolicyRegistryModule.select` 在 `administration` 频道选中 `governor_bonus`。
- 生产与 AI:`Production.setSlotCount` / `enqueue` 在同一基地混放 `build_unit`(`tank.medium`) 与
`issue_decree`(`decree.tax_reform`),`advance` 按 `production_speed` 最终值推进;
`Sensing.circle` 查到最近敌军后交给 `Decision.setState` / `addTransition` / `trigger` / `choose`,
`Steering.arrive` 求朝向速度,`Decision.sample("threat", ...)` 采样 8×8 威胁网格。
- 状态效果与判定:`Effects.apply` 施加 `salary_unpaid`,`addTag("politics")` 打标签、payload 写入
`loyalty_delta`,再用 `addModifier` 叠加 `loyalty` 负修正;`evaluate_rebellion` 按
`getFinal("loyalty")`、`ambition`、`relation(..., "support")` 三重阈值判定,并由
`Transaction.create` / `stage` / `markValid` / `validate` / `commit` 提交三步 `set_owner` 计划。
- 原子切换与投影:`StatePatch.newBatch` + `setExpected(..., "faction.frontier", "faction.crown")`
校验后一次 `commit`,冲突则 `plan.fail("ownership_conflict")`;成功后 `Orders.newQueueOwned` /
`append` 下发 `secure_assets`,`GameEvent.append` 记录 `rebellion_started`,
`Social.setOwner` / `setRelation` 只作为查询投影同步。
## 观察方式
启动时 `eve_init` 调用 `run_rebellion_scenario()`,只打印一行
`rebellion=... base_owner=... production=...`:`rebellion` 为 `true` 且 `base_owner` 为
`faction.frontier`,说明事务提交与状态补丁都成功;`production` 不再是初始的 `1.0`,因为
`refresh_governor_bonus` 用 `addModifier("governor.production", "production_speed", ...,`
`"multiply", ...)` 挂了州长加成。任一步失败时会经 `decision_result_ok` 打印
`decision <operation> failed: <status.summary>` 便于定位;`eve_render` 用 `gfx.drawSolidRect` 画
左右两块,叛乱成功后左块(王权)红通道由 0.65 降至 0.18、右块(边疆)绿通道由 0.20 升至 0.72。
## 相关文件
- `main.nut`:入口——设置背景色、调用 `run_rebellion_scenario()`、打印结果并渲染两个色块。
- `simulation.nut`:全部玩法逻辑——模块装配、生产与 AI、状态效果、叛乱事务与状态补丁。
- `config.nut`:窗口 960×540、标题 `Composable Gameplay: Rebellion`、`debug` 与 `hotReload` 均为 `true`。
- `README.md`:本说明。
+43
View File
@@ -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` | 本文档 |
+77
View File
@@ -0,0 +1,77 @@
# Fluid3D Lab(3D 体积流体实验室)
交互式 3D 体积流体 / 颗粒实验室:一个可运行项目里覆盖 19 个场景模式(`mode` 0–18),
外加 70 多个 opt-in 数值验收脚本。窗口标题 `EVEngine | Fluid3D Lab`,启动后默认**暂停**。
演示的能力:体积流体求解器(SPH 类)的材质 / 相态、表面重建与各向异性、颗粒发射器与
生命周期、碰撞体与接触抓取、热耦合、泡沫池、移动 SDF 障碍、重叠查询、网格化输出,
以及相机与渲染设置(折射 / 反射 / 粒子 impostor / 正交相机)等。
## 运行
```bash
cd examples/fluid3d
../../build/linux-debug/src/engine/eve run
```
或从仓库根目录:
```bash
make run/<platform>-debug GAME=examples/fluid3d # <platform> = win32 / linux / macosx
```
## 操作
| 按键 | 作用 |
|---|---|
| `Space` | 暂停 / 继续(启动时是暂停状态) |
| `R` | 按当前模式重建场景 |
| `1`–`9` | Water / Viscous / Smoke / Granular / Jet / Mesh / Moving / Multiphase / Wheel |
| `0` | Mixing(左侧加热、右侧冷却,颜色跟随黏度) |
| `T` | Thermal(热耦合与热色) |
| `S` | Solid attachment(固体附着,跟随运动碰撞体) |
| `F` | Foam(泡沫池与合成耗时) |
| `D` | SDF(只做变换更新的移动 SDF 障碍) |
| `Q` | Query(重叠触发器,红 / 黄计数) |
| `M` | Maze(迷宫;模式内用 `A` / `D` 调角速度) |
| `B` | Bucket(水桶) |
| `W` | Bottle(威士忌瓶;按住 `D` 倾倒) |
| `K` | Wake(卡门涡街尾流) |
HUD 会实时打印粒子数、掉帧丢弃的秒数以及 Sim / Surface / Upload 三个阶段的耗时;
求解失败时窗口内显示错误提示,控制台打印 `VOLUME_FLUID_STOPPED: <原因>`。
## 文件
| 路径 | 说明 |
|---|---|
| `main.nut` | 入口:模式注册、按键分发、每帧求解与表面上传 |
| `config.nut` | 引擎 `config`:960×720、`hotReload = true` |
| `maze-support.nut` | 迷宫模式的几何与状态 |
| `bucket-support.nut` | 水桶模式 |
| `bottle-support.nut` | 威士忌瓶模式(姿态更新的 SDF) |
| `karman-support.nut` | 卡门涡街尾流模式 |
| `wave-support.nut` | 波浪发生器 |
| `*-check.nut` | opt-in 数值验收脚本(见下) |
| `assets/` | OBJ 网格(发射环、酒瓶等) |
| `fonts/` | `DejaVuSans-Bold.ttf` 与授权说明 |
| `eve_snapshot.json` | 示例快照 |
## opt-in 数值验收脚本
`*-check.nut` 都是**可选**探针,不会被 `main.nut` 自动加载;在运行中的示例里按需加载并
调用对应函数,例如:
```squirrel
dofile("thermal-check.nut"); verifyVolumeFluidThermalContact();
dofile("soak-check.nut"); beginVolumeFluidSoak();
```
它们用固定的步数与数值容差验证求解结果(粒子数、位移下限、表面能量等),失败时直接
`throw`。完整的脚本清单、每个探针覆盖的 API 与判定条件见
[`docs/usr/fluid3d.md`](../../docs/usr/fluid3d.md)。
## 契约
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
+105
View File
@@ -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.
+9
View File
@@ -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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

+359
View File
@@ -0,0 +1,359 @@
// examples/hex-planet -- the hex terrain system wrapped onto a sphere, from orbit.
//
// This is the spherical counterpart of `examples/hex-terrain-3d`: the same module,
// the same `HexCellData` / `HexValues` / `HexFlags` records and the same terrain
// vertex encoding, but the grid is an icosahedral (Goldberg) topology reached through
// `hexmap`'s sphere bindings instead of the flat chunk grid.
//
// Everything geometric lives in C++ (`HexSphereTopology`, `HexSphereMap`,
// `HexSphereMesh`, `HexSphereGenerator`); this script owns the orbit camera, the
// input mapping, the two renderables and the HUD.
const DEG_TO_RAD = 0.017453292519943295;
// Topology levels: cells = 10 * 4^s + 2. Level 4 is 2562 cells and is the default;
// level 6 is 40962 and still generates and builds in well under a second.
const SUBDIVISION_MIN = 1;
const SUBDIVISION_MAX = 6;
const DEFAULT_SUBDIVISION = 4;
const DEFAULT_RADIUS = 100.0;
const DEFAULT_SEED = 20260918;
const DEFAULT_LAND = 45;
const DEFAULT_WATER_LEVEL = 0;
// Framing: the globe has to fit the vertical field of view with room for the relief.
const FOV_DEGREES = 45.0;
const FIT_MARGIN = 1.16;
const ORBIT_SPEED = 0.30; // degrees of rotation per pixel dragged
const PITCH_MIN = -78.0; // never exactly +/-90: the up vector degenerates there
const PITCH_MAX = 78.0;
const ZOOM_STEP = 0.12;
const ZOOM_MIN = 1.12; // as a multiple of the framing distance
const ZOOM_MAX = 4.50;
const SPIN_SPEED = 4.0; // degrees per second while auto-spin is on
persist planet = null;
function createState() {
return {
camera = null,
terrainShader = null,
waterShader = null,
terrain = null,
water = null,
subdivision = DEFAULT_SUBDIVISION,
radius = DEFAULT_RADIUS,
seed = DEFAULT_SEED,
landPercent = DEFAULT_LAND,
waterLevel = DEFAULT_WATER_LEVEL,
yaw = 35.0,
pitch = 38.0,
zoom = 1.0,
spinning = false,
dragging = false,
lastMouseX = 0,
lastMouseY = 0,
elapsed = 0.0,
frames = 0,
screenshotSaved = false,
statusText = "building...",
uiBuilt = false,
triangles = 0,
};
}
// --- camera -----------------------------------------------------------------
function fitDistance(st) {
return (st.radius * FIT_MARGIN) / sin(FOV_DEGREES * 0.5 * DEG_TO_RAD);
}
function applyCamera(st) {
if (st.camera == null) return;
local distance = fitDistance(st) * st.zoom;
local yawRad = st.yaw * DEG_TO_RAD;
local pitchRad = st.pitch * DEG_TO_RAD;
local horizontal = cos(pitchRad) * distance;
st.camera.setEye(horizontal * sin(yawRad), sin(pitchRad) * distance, horizontal * cos(yawRad));
// The globe is centred on the origin, so the target is its centre and the relief
// is symmetric around it.
st.camera.setTarget(0.0, 0.0, 0.0);
st.camera.setClipPlanes(distance * 0.02, distance * 3.0);
}
function updateCamera(st, dt) {
if (st.spinning) st.yaw += SPIN_SPEED * dt;
local x = mouse.getX();
local y = mouse.getY();
local down = mouse.isDown(1);
if (down) {
if (st.dragging) {
st.yaw -= (x - st.lastMouseX) * ORBIT_SPEED;
st.pitch = clampf(st.pitch + (y - st.lastMouseY) * ORBIT_SPEED, PITCH_MIN, PITCH_MAX);
}
st.dragging = true;
} else {
st.dragging = false;
}
st.lastMouseX = x;
st.lastMouseY = y;
local wheel = mouse.getWheelY();
if (wheel != 0.0) st.zoom = clampf(st.zoom - wheel * ZOOM_STEP, ZOOM_MIN, ZOOM_MAX);
applyCamera(st);
}
// --- shaders and renderables ------------------------------------------------
function createShaders(st) {
if (st.terrainShader != null) return true;
// The engine's built-in Mesh3D vertex stage already produces the varyings both
// fragment stages consume, so only the fragment stages ship -- as SPIR-V, because
// runtime GLSL compilation is unavailable on Windows.
local terrain = gfx.loadMeshShaderSpv("", "shaders/hex_planet_terrain.frag.spv");
if (!terrain.ok) {
print("hex planet: terrain shader failed: " + terrain.status.summary + "\n");
return false;
}
local water = gfx.loadMeshShaderSpv("", "shaders/hex_planet_water.frag.spv");
if (!water.ok) {
print("hex planet: water shader failed: " + water.status.summary + "\n");
return false;
}
st.terrainShader = terrain.value;
st.waterShader = water.value;
return true;
}
function createRenderables(st) {
if (!createShaders(st)) return;
st.terrain = eve.Renderable3D();
st.terrain.setShader(st.terrainShader);
// The globe does not cast onto or receive from anything here: there is one
// surface, and self-shadowing it only produces acne along every hex boundary.
st.terrain.setCastShadow(false);
st.terrain.setReceiveShadow(false);
st.terrain.setVisible(false);
st.water = eve.Renderable3D();
st.water.setShader(st.waterShader);
st.water.setCastShadow(false);
st.water.setReceiveShadow(false);
st.water.setVisible(false);
}
function bindMeshes(st) {
if (st.terrain == null) return;
local terrainMesh = hexmap.sphereTerrainMesh();
if (terrainMesh != null) {
st.terrain.setMesh(terrainMesh);
st.terrain.setVisible(true);
} else {
st.terrain.setVisible(false);
}
local waterMesh = hexmap.sphereWaterMesh();
if (waterMesh != null) {
st.water.setMesh(waterMesh);
st.water.setVisible(true);
} else {
// Nothing is flooded at this sea level; the renderable must not keep drawing
// the previous ocean.
st.water.setVisible(false);
}
}
// --- world ------------------------------------------------------------------
function describePlanet(st) {
return hexmap.sphereCellCount() + " cells, " + hexmap.spherePentagonCount() + " pentagons, " +
hexmap.sphereSubdivision() + " subdivisions";
}
/** Reports what the generator actually produced, so a washed-out frame is measurable. */
function reportDistribution(st) {
local counts = [0, 0, 0, 0, 0];
local underwater = 0;
local count = hexmap.sphereCellCount();
for (local cell = 0; cell < count; cell++) {
local terrain = hexmap.sphereTerrainType(cell);
if (terrain >= 0 && terrain < 5) counts[terrain] = counts[terrain] + 1;
if (hexmap.sphereIsUnderwater(cell)) underwater = underwater + 1;
}
print("hex planet: sand " + counts[0] + " grass " + counts[1] + " mud " + counts[2] + " stone " + counts[3] +
" snow " + counts[4] + " | underwater " + underwater + " of " + count + "\n");
print("hex planet: terrain mesh " + (hexmap.sphereTerrainMesh() != null) + ", water mesh " +
(hexmap.sphereWaterMesh() != null) + "\n");
}
function buildPlanet(st) {
local created = hexmap.newSphere(gfx, st.subdivision, st.radius, st.seed);
if (!created.ok) {
st.statusText = "sphere failed: " + created.status.summary;
print("hex planet: " + st.statusText + "\n");
return false;
}
local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel);
if (!generated.ok) {
st.statusText = "generate failed: " + generated.status.summary;
print("hex planet: " + st.statusText + "\n");
return false;
}
st.triangles = 0;
st.statusText = describePlanet(st);
print("hex planet: " + describePlanet(st) + ", radius " + st.radius + ", seed " + st.seed + "\n");
print("hex planet: " + st.landPercent + "% land, water line " + st.waterLevel + ", spacing " +
hexmap.sphereCellSpacing() + "\n");
reportDistribution(st);
print("hex planet: LMB drag orbit | wheel zoom | space spin | R new seed | [ ] subdivision | - = land\n");
return true;
}
function regenerate(st) {
local generated = hexmap.generateSphere(gfx, st.seed, st.landPercent, st.waterLevel);
if (!generated.ok) {
st.statusText = "generate failed: " + generated.status.summary;
return;
}
bindMeshes(st);
st.statusText = describePlanet(st) + ", seed " + st.seed;
}
// --- input ------------------------------------------------------------------
function handleKeys(st) {
if (key_just_pressed("r")) {
st.seed += 1;
regenerate(st);
print("hex planet: seed " + st.seed + "\n");
}
if (key_just_pressed("space", "Space")) {
st.spinning = !st.spinning;
st.statusText = st.spinning ? "auto-spin on" : "auto-spin off";
}
if (key_just_pressed("escape", "Escape")) {
st.spinning = false;
st.yaw = 35.0;
st.pitch = 38.0;
st.zoom = 1.0;
applyCamera(st);
}
if (key_just_pressed("F5")) {
if (gfx.saveFramePng("hex-planet.png")) st.statusText = "saved hex-planet.png";
}
// Rebuilding a subdivision level replaces the whole topology, so it goes through
// the full create path rather than the cheaper regenerate.
local subdivision = st.subdivision;
if (key_just_pressed("]")) subdivision = subdivision + 1;
if (key_just_pressed("[")) subdivision = subdivision - 1;
if (subdivision < SUBDIVISION_MIN) subdivision = SUBDIVISION_MIN;
if (subdivision > SUBDIVISION_MAX) subdivision = SUBDIVISION_MAX;
if (subdivision != st.subdivision) {
st.subdivision = subdivision;
buildPlanet(st);
bindMeshes(st);
}
local land = st.landPercent;
if (key_just_pressed("=")) land = land + 5;
if (key_just_pressed("-")) land = land - 5;
if (land < 5) land = 5;
if (land > 95) land = 95;
if (land != st.landPercent) {
st.landPercent = land;
regenerate(st);
print("hex planet: " + land + "% land\n");
}
}
// --- HUD --------------------------------------------------------------------
function buildHud(st) {
// Widgets only exist inside a window, so the panel is built once and then updated
// by label in `refreshHud`.
ui.beginBuild();
ui.beginWindow("HexPlanet", "root");
ui.text("EVEngine Hex Planet", "title");
ui.text("", "planet");
ui.text("", "view");
ui.separator("sep");
ui.text("[LMB]orbit [wheel]zoom [space]spin [R]seed", "help1");
ui.text("[[/]]subdivision [-/=]land [F5]shot [esc]reset", "help2");
ui.end();
ui.mountBuildAs("hud");
ui.select("hud");
ui.setHostOverlay(true);
ui.setHostVisible(true);
ui.setHostPos(12.0, 12.0, 0.0, 0.0);
st.uiBuilt = true;
}
function refreshHud(st) {
if (!st.uiBuilt) return;
ui.select("hud");
ui.setText("planet", "planet: " + st.statusText);
ui.setText("view", "view: yaw " + st.yaw + " pitch " + st.pitch + " zoom " + st.zoom);
}
// --- entry points -----------------------------------------------------------
eve_init = function() {
if (planet == null) planet = createState();
local st = planet;
st.camera = eve.Camera3D();
st.camera.setFov(FOV_DEGREES);
// The default view looks down on the northern cap (pitch 38) and the light sits well
// off the view axis: a lower pitch foreshortens the ice into the very top edge, and a
// light near the camera puts every visible pixel at N.L ~ 1, which flattens the globe
// into a bright disc with no terminator to read the sphere's shape from.
st.camera.setAmbient(0.17, 0.20, 0.27);
st.camera.setActive(true);
gfx.setBackgroundColor(0.012, 0.016, 0.030, 1.0);
gfx.setDirectionalLight(-0.78, 0.42, 0.46, 1.45, 1.34, 1.16);
gfx.setCloudShadows(0.22, 6.0, 0.0, 0.42, 0.40, 0.52, 0.62);
createRenderables(st);
buildPlanet(st);
bindMeshes(st);
buildHud(st);
applyCamera(st);
};
eve_update = function(dt) {
local st = planet;
if (st == null) return;
st.elapsed += dt;
st.frames += 1;
handleKeys(st);
updateCamera(st, dt);
refreshHud(st);
// Readback is enabled by the first call, so retry until the frame lands. The
// default pose is static, so the saved frame is reproducible run to run.
if (!st.screenshotSaved && st.frames > 90 && gfx.saveFramePng("hex-planet.png"))
st.screenshotSaved = true;
};
eve_render = function() {
gfx.clear();
gfx.render3D();
ui.beginFrameAndRender();
};
@@ -0,0 +1,168 @@
#version 450
// Terrain surface of the spherical hex map (examples/hex-planet).
//
// The vertex encoding is exactly the one the planar builder emits, so this shader
// and `hex_map_terrain.frag` share the decode and the palette (see
// HexMapMesh.h / HexTerrainVertexCode):
// uv.x = layerA + layerB * 8 + layerC * 64
// uv.y = weightB + weightC * 16
//
// What cannot be shared is everything that quietly assumed a flat map. The planar
// shader reads `vWorldPos.y` as altitude, `vNormal.y` as "up", projects its detail
// noise into XZ and fades the far distance into fog; on a sphere Y spans the whole
// planet, XZ collapses at the poles, the surface normal is the radial direction and
// there is no horizon to fade into. Those four are replaced by radial equivalents.
// The palette and the BRDF below are deliberately the same numbers.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
/**
* Detail-noise coordinates of a point on the sphere.
*
* Dropping the dominant axis keeps the field roughly isotropic. Projecting into XZ
* as the planar shader does would collapse to a point at both poles, which reads as
* a radial smear of stretched noise exactly where the twelve pentagons sit.
*/
vec2 sphereCoords(vec3 dir) {
vec3 a = abs(dir);
if (a.y >= a.x && a.y >= a.z) return dir.xz;
if (a.x >= a.z) return dir.yz;
return dir.xy;
}
/**
* Inverts the host's own tonemap.
*
* A debug fragment stage that emitted a constant 1.0 arrives in the frame buffer as
* 224/255, i.e. the host applies a second, Reinhard-style curve `y = x / (x + 0.139)`
* to whatever this stage returns. This shader authors final sRGB values, so it undoes
* that curve instead of being silently darkened by it; without this the palette below
* cannot be tuned at all, because every change is compressed by an invisible stage.
*/
vec3 hostTonemapInverse(vec3 srgb) {
srgb = clamp(srgb, vec3(0.0), vec3(0.95));
return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001));
}
// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow.
vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) {
if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain);
if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3);
if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain);
if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain);
return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35);
}
float terrainRoughness(int id) {
if (id == 0) return 0.92;
if (id == 1) return 0.88;
if (id == 2) return 0.74;
if (id == 3) return 0.68;
return 0.42;
}
void main() {
float packedIndices = vUV.x;
float packedWeights = vUV.y;
int layerA = int(mod(packedIndices, 8.0) + 0.5);
int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5);
int layerC = int(floor(packedIndices / 64.0) + 0.5);
float weightB = fract(packedWeights);
float weightC = floor(packedWeights) / 16.0;
float weightA = max(0.0, 1.0 - weightB - weightC);
float time = ubo.cloud.z;
vec3 radial = normalize(vWorldPos);
vec2 p = sphereCoords(radial) * 14.0;
float grain = noise(p * 9.0);
float patchiness = fbm(p * 1.6);
vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA
+ terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB
+ terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC;
float roughness = terrainRoughness(layerA) * weightA
+ terrainRoughness(layerB) * weightB
+ terrainRoughness(layerC) * weightC;
// The generator already places the snow line, the rock and the deserts, so the
// continental scale is in the vertex layers; this only breaks up the flat fills.
albedo *= mix(0.90, 1.08, fbm(p * 0.8));
float detail = fbm(p * 6.0);
vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
float k = pow(roughness + 1.0, 2.0) / 8.0;
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
vec3 F0 = vec3(0.04);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
// 1.5 rather than the planar shader's 2.6: a globe has no distance fog and far less
// self-occlusion, so the same light energy lands on every visible pixel and the
// snow and sand fills blow straight through the tonemap.
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 1.5;
// "Up" is the radial direction here, not world Y: N.y would run from -1 at the
// south pole to +1 at the north and turn the sky term into a latitude gradient.
float up = dot(N, radial);
float sky = max(0.0, up) * 0.16 + 0.06;
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky);
// Slope shading so cliffs read as rock even without a texture.
color *= mix(0.72, 1.0, clamp(up * 1.35 + 0.20, 0.0, 1.0));
// Cloud banding, sampled on the sphere rather than in world XZ.
float cloudField = fbm(sphereCoords(radial) * 22.0 + ubo.cloudWind.xy * time * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
// Reinhard keeps mid-tones saturated instead of clipping to white.
color = color / (1.0 + color * 1.05);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
// No distance fog: a planet seen from orbit has no haze band to fade into, and
// the planar `smoothstep(240, 520, viewDepth)` would erase the whole globe. A
// limb glow takes its place so the silhouette separates from the starfield.
float rim = pow(1.0 - clamp(ndv, 0.0, 1.0), 3.0);
color += vec3(0.10, 0.22, 0.44) * rim * 0.40;
outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0);
}
@@ -0,0 +1,104 @@
#version 450
// Ocean surface of the spherical hex map (examples/hex-planet).
//
// Shares the planar water contract for its texture coordinates -- `vUV.x` is the
// shore parameter, 0 in open ocean and 1 where the water ends against land -- but
// takes its wave coordinates from the sphere rather than from world XZ, for the
// same reason the terrain shader does: XZ collapses at the poles.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
vec2 sphereCoords(vec3 dir) {
vec3 a = abs(dir);
if (a.y >= a.x && a.y >= a.z) return dir.xz;
if (a.x >= a.z) return dir.yz;
return dir.xy;
}
/** Inverts the host's second tonemap stage; see the terrain shader for the measurement. */
vec3 hostTonemapInverse(vec3 srgb) {
srgb = clamp(srgb, vec3(0.0), vec3(0.95));
return (0.139 * srgb) / max(vec3(1.0) - srgb, vec3(0.001));
}
void main() {
float time = ubo.cloud.z;
vec3 radial = normalize(vWorldPos);
vec2 p = sphereCoords(radial) * 26.0;
// Two travelling wave trains plus fbm chop: a flat normal makes an ocean read as
// a painted disc from orbit, where the only clue to liquid is the specular lobe.
float wave = sin(p.x * 0.55 + p.y * 0.35 - time * 1.05) * 0.45
+ sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28
+ fbm(p * 1.4 + time * 0.05) * 0.32;
float shore = clamp(vUV.x, 0.0, 1.0);
vec3 deep = vec3(0.012, 0.045, 0.095);
vec3 shallow = vec3(0.040, 0.170, 0.240);
vec3 albedo = mix(deep, shallow, shore * 0.85);
// Sea ice. Without it the polar oceans stay dark navy under a grazing light angle
// and the caps read as holes rather than ice.
float ice = smoothstep(0.80, 0.94, abs(radial.y));
albedo = mix(albedo, vec3(0.74, 0.80, 0.87), ice);
vec3 N = normalize(vNormal + vec3(dFdx(wave), 0.0, dFdy(wave)) * 0.55 + radial * wave * 0.035);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
// Tight, strong highlight: the sun glint is what sells the surface as water.
float shininess = mix(64.0, 900.0, 1.0 - fbm(p * 0.6));
float spec = pow(ndh, shininess) * 0.40;
vec3 F = vec3(0.02) + vec3(0.98) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 color = albedo * (ubo.ambient.rgb * 0.55 + ubo.lightColor.rgb * ndl * 0.72);
color += F * spec * ubo.lightColor.rgb * 3.0;
// Fresnel on the limb: shallow grazing angles turn the ocean into a mirror of the
// sky, which is also what keeps the far hemisphere from reading as flat navy.
float fresnel = pow(1.0 - clamp(ndv, 0.0, 1.0), 4.0);
color = mix(color, vec3(0.22, 0.42, 0.68), fresnel * 0.70);
color = color / (1.0 + color * 1.05);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
outColor = vec4(hostTonemapInverse(color * vTint.rgb), 1.0);
}
+149
View File
@@ -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.
+9
View File
@@ -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
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,118 @@
#version 450
// Structures and decorations of the hex map: city/farm walls, wall towers,
// bridges and the urban / farm / plant / special decorations.
//
// Two mesh streams feed this shader (`HexSurface::Wall` and
// `HexSurface::Feature`); the mesh builders encode which part a vertex belongs
// to in the first texture coordinate:
//
// 0 wall 1 tower 2 bridge
// 3 urban 4 farm 5 plant 6 special
//
// Because the parts are drawn with different materials but share one pipeline,
// the shader picks its albedo/roughness from that code rather than from a
// texture, which keeps the example asset-free.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
// Codes are written as integer-valued floats; a half step snaps them back.
int part = int(floor(vUV.x + 0.5));
vec3 albedo;
float roughness;
if (part == 0) {
// Wall: dressed stone with a coursing pattern so the thin strips read.
float course = step(0.5, fract(vWorldPos.y * 0.35));
albedo = mix(vec3(0.46, 0.44, 0.40), vec3(0.58, 0.56, 0.51), course);
roughness = 0.78;
} else if (part == 1) {
// Tower: slightly lighter stone, keeps the silhouette distinct.
albedo = vec3(0.63, 0.61, 0.56);
roughness = 0.70;
} else if (part == 2) {
// Bridge: timber planks across the channel.
float plank = step(0.5, fract(vUV.y * 8.0));
albedo = mix(vec3(0.36, 0.25, 0.15), vec3(0.46, 0.33, 0.20), plank);
roughness = 0.85;
} else if (part == 3) {
// Urban: plastered walls and a tiled roof, split by height.
albedo = vWorldPos.y > 0.0 ? vec3(0.72, 0.68, 0.60) : vec3(0.55, 0.30, 0.24);
albedo = mix(vec3(0.55, 0.30, 0.24), vec3(0.78, 0.74, 0.66),
step(0.5, fract(vWorldPos.y * 0.5 + 0.5)));
roughness = 0.72;
} else if (part == 4) {
// Farm: tilled rows.
float row = noise(vWorldPos.xz * 0.9);
albedo = mix(vec3(0.52, 0.42, 0.22), vec3(0.66, 0.55, 0.30), row);
roughness = 0.92;
} else if (part == 5) {
// Plant: foliage, darker at the base.
float shade = clamp(vNormal.y * 0.4 + 0.6, 0.0, 1.0);
albedo = mix(vec3(0.14, 0.30, 0.11), vec3(0.30, 0.52, 0.18), shade);
roughness = 0.88;
} else {
// Special: pale monument stone.
albedo = mix(vec3(0.62, 0.60, 0.55), vec3(0.80, 0.79, 0.75),
step(0.5, fract(vWorldPos.y * 0.28)));
roughness = 0.60;
}
vec3 N = normalize(vNormal);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
float k = pow(roughness + 1.0, 2.0) / 8.0;
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
vec3 F0 = vec3(0.04);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.2;
float sky = max(0.0, N.y) * 0.18 + 0.10;
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.60 + vec3(0.10, 0.12, 0.15) * sky);
float cloudField = noise(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
color = color / (1.0 + color * 0.72);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,71 @@
#version 450
// Fog-of-war overlay of the hex map.
//
// The mesh builder (HexFogMesh.cpp) covers every cell that is not currently
// fully visible with a hexagonal column and encodes the shade in the first
// texture coordinate:
// uv.x = 0 -> explored before, but no viewer sees it right now (dim)
// uv.x = 1 -> never explored (opaque black)
//
// The shader outputs final display colours directly: the overlay is an
// unlit UI-like surface, so the lit-surface tone mapping of the terrain shader
// would darken it a second time.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
void main() {
float unknown = step(0.5, vUV.x);
// The renderer tone-maps and gamma-encodes this surface like any other, so
// these are *linear* values: a mid grey-blue for ground that was explored but
// is not watched right now, and near black for ground never seen at all.
vec3 seen = vec3(0.10, 0.13, 0.20);
vec3 unseen = vec3(0.015, 0.02, 0.04);
vec3 color = mix(seen, unseen, unknown);
// Large-scale mottling keeps the flat column tops from reading as plastic.
float mottle = fbm(vWorldPos.xz * 0.06);
color *= 0.86 + 0.28 * mottle * (1.0 - unknown * 0.6);
// A faint lift on the vertical walls so the fog column has a silhouette.
float wall = 1.0 - abs(normalize(vNormal).y);
color *= 1.0 - wall * 0.22;
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * ubo.cloud.z * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
Binary file not shown.
@@ -0,0 +1,69 @@
#version 450
// River channel of the hex map. uv.x runs along the channel, uv.y across it.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float along = vUV.x * 6.0;
float across = clamp(vUV.y, 0.0, 1.0);
vec2 p = vWorldPos.xz;
// Water flows downstream: ripples travel with +uv.x.
float ripple =
sin((p.x * 0.55 + p.y * 0.35) - time * 2.6) * 0.45 +
sin((p.x * 1.30 - p.y * 0.90) - time * 4.1) * 0.25 +
noise(p * 1.9 - vec2(time * 0.9, 0.0)) * 0.35;
float bank = smoothstep(0.0, 0.35, across) * smoothstep(1.0, 0.65, across);
vec3 N = normalize(vec3(dFdx(ripple), 1.0, dFdy(ripple)) * 1.8 + vNormal);
N = normalize(mix(vNormal, N, 0.35 + 0.55 * bank));
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndv = max(dot(N, V), 0.001);
vec3 albedo = mix(vec3(0.05, 0.20, 0.26), vec3(0.10, 0.40, 0.44), bank);
float fresnel = 0.03 + 0.97 * pow(1.0 - ndv, 4.0);
vec3 color = mix(albedo, vec3(0.32, 0.52, 0.62), fresnel * 0.55);
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 72.0) * 0.70;
color += albedo * ubo.ambient.rgb;
// Wet banks darken and desaturate towards the channel edges.
color *= mix(0.72, 1.10, bank);
color += vec3(0.55, 0.72, 0.70) * pow(max(dot(N, H), 0.0), 28.0) * (1.0 - bank) * 0.35;
color = 1.0 - exp(-color * 1.3);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,58 @@
#version 450
// Road ribbon of the hex map. uv.x runs along the road, uv.y across it.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float across = clamp(vUV.y, 0.0, 1.0);
vec2 p = vWorldPos.xz;
float grain = noise(p * 3.4);
// Two wheel ruts with a lighter crown, like the reference road material.
float ruts = smoothstep(0.10, 0.22, abs(across - 0.28)) * smoothstep(0.10, 0.22, abs(across - 0.72));
vec3 albedo = mix(vec3(0.30, 0.25, 0.18), vec3(0.46, 0.40, 0.31), grain);
albedo = mix(albedo * 0.72, albedo, ruts);
albedo *= smoothstep(0.0, 0.14, across) * smoothstep(1.0, 0.86, across) * 0.35 + 0.65;
vec3 N = normalize(vNormal + vec3(dFdx(grain), 0.0, dFdy(grain)) * 0.10);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
float ndl = max(dot(N, L), 0.0);
vec3 color = albedo * (ubo.ambient.rgb + ubo.lightColor.rgb * ndl * 1.35);
color *= mix(0.80, 1.0, clamp(N.y * 1.2 + 0.2, 0.0, 1.0));
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
color = 1.0 - exp(-color * 1.25);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,121 @@
#version 450
// Terrain surface of the hex map.
//
// The mesh builder packs three terrain layers into the two available texture
// coordinates (see HexMapMesh.h):
// uv.x = layerA + layerB * 8 + layerC * 64
// uv.y = weightB + weightC * 16
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
float fbm(vec2 p) {
float s = 0.0, a = 0.5;
for (int i = 0; i < 4; ++i) { s += noise(p) * a; p = mat2(1.7, -1.3, 1.3, 1.7) * p; a *= 0.5; }
return s;
}
// Unity-parity palette: 0 Sand, 1 Grass, 2 Mud, 3 Stone, 4 Snow.
vec3 terrainAlbedo(int id, vec2 p, float grain, float patchiness) {
if (id == 0) return mix(vec3(0.74, 0.66, 0.42), vec3(0.88, 0.80, 0.55), grain);
if (id == 1) return mix(vec3(0.24, 0.45, 0.14), vec3(0.44, 0.62, 0.22), grain * 0.7 + patchiness * 0.3);
if (id == 2) return mix(vec3(0.31, 0.23, 0.14), vec3(0.45, 0.35, 0.22), grain);
if (id == 3) return mix(vec3(0.36, 0.36, 0.37), vec3(0.62, 0.61, 0.58), grain);
return mix(vec3(0.80, 0.86, 0.92), vec3(0.98, 0.99, 1.00), grain * 0.6 + 0.35);
}
float terrainRoughness(int id) {
if (id == 0) return 0.92;
if (id == 1) return 0.88;
if (id == 2) return 0.74;
if (id == 3) return 0.68;
return 0.42;
}
void main() {
float packedIndices = vUV.x;
float packedWeights = vUV.y;
int layerA = int(mod(packedIndices, 8.0) + 0.5);
int layerB = int(mod(floor(packedIndices / 8.0), 8.0) + 0.5);
int layerC = int(floor(packedIndices / 64.0) + 0.5);
float weightB = fract(packedWeights);
float weightC = floor(packedWeights) / 16.0;
float weightA = max(0.0, 1.0 - weightB - weightC);
float time = ubo.cloud.z;
vec2 p = vWorldPos.xz * 0.55;
float grain = noise(p * 9.0);
float patchiness = fbm(p * 1.6);
vec3 albedo = terrainAlbedo(layerA, p, grain, patchiness) * weightA
+ terrainAlbedo(layerB, p + 11.3, grain, patchiness) * weightB
+ terrainAlbedo(layerC, p + 23.9, grain, patchiness) * weightC;
float roughness = terrainRoughness(layerA) * weightA
+ terrainRoughness(layerB) * weightB
+ terrainRoughness(layerC) * weightC;
// Snow line and rock reach on high ground, plus large-scale colour breakup.
float altitude = vWorldPos.y;
float snow = smoothstep(15.0, 21.0, altitude + fbm(p * 2.3) * 2.2);
float rock = smoothstep(0.55, 0.95, 1.0 - abs(vNormal.y));
albedo = mix(albedo, vec3(0.42, 0.40, 0.37), rock * 0.55);
albedo = mix(albedo, vec3(0.94, 0.96, 0.99), snow * 0.85);
roughness = mix(roughness, 0.38, snow * 0.7);
float detail = fbm(p * 6.0);
vec3 N = normalize(vNormal + vec3(dFdx(detail), 0.0, dFdy(detail)) * 0.22);
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
float ndh = max(dot(N, H), 0.0);
float alpha = max(0.03, roughness * roughness), a2 = alpha * alpha;
float D = a2 / max(PI * pow(ndh * ndh * (a2 - 1.0) + 1.0, 2.0), 0.001);
float k = pow(roughness + 1.0, 2.0) / 8.0;
float G = (ndl / (ndl * (1.0 - k) + k)) * (ndv / (ndv * (1.0 - k) + k));
vec3 F0 = vec3(0.04);
vec3 F = F0 + (1.0 - F0) * pow(1.0 - max(dot(H, V), 0.0), 5.0);
vec3 spec = D * G * F / max(4.0 * ndl * ndv, 0.001);
vec3 direct = ((1.0 - F) * albedo / PI + spec) * ndl * ubo.lightColor.rgb * 2.6;
float sky = max(0.0, N.y) * 0.16 + 0.06;
vec3 color = direct + albedo * (ubo.ambient.rgb * 0.55 + vec3(0.10, 0.12, 0.15) * sky);
// Slope shading so cliffs read as rock even without a texture.
color *= mix(0.72, 1.0, clamp(N.y * 1.35 + 0.20, 0.0, 1.0));
float cloudField = fbm(vWorldPos.xz * 0.028 + ubo.cloudWind.xy * time * 0.012);
float cloudMask = smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField);
color *= 1.0 - ubo.cloud.x * cloudMask * 0.30;
// Reinhard keeps mid-tones saturated instead of clipping to white.
color = color / (1.0 + color * 0.72);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
@@ -0,0 +1,82 @@
#version 450
// Water surface of the hex map. uv.x is the shore parameter emitted by the
// water mesh builder: 0 in open water, approaching 1 on the shoreline band.
layout(location=0) in vec3 vNormal;
layout(location=1) in vec2 vUV;
layout(location=2) in vec4 vTint;
layout(location=3) in vec3 vWorldPos;
layout(location=4) in vec3 vCameraPos;
layout(location=5) in vec3 vViewPos;
struct Light3D { vec4 posRadius; vec4 color; };
layout(set=0,binding=0,std140) uniform Frame {
mat4 mvp; mat4 model; vec4 lightDirIntensity; vec4 lightColor; vec4 tint;
vec4 cameraPos; vec4 ambient; Light3D lights[8]; vec4 texBomb; vec4 parallax;
mat4 view; vec4 clipInfo; vec4 cloud; vec4 cloudWind;
} ubo;
layout(location=0) out vec4 outColor;
const float PI = 3.14159265359;
float hash21(vec2 p) {
p = fract(p * vec2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(hash21(i), hash21(i + vec2(1, 0)), f.x),
mix(hash21(i + vec2(0, 1)), hash21(i + vec2(1, 1)), f.x), f.y);
}
void main() {
float time = ubo.cloud.z;
float shore = clamp(vUV.x, 0.0, 1.0);
vec2 p = vWorldPos.xz;
// Two crossing wave trains plus fine chop; the shoreline gets shorter waves.
float wave =
sin(p.x * 0.42 + time * 1.35) * 0.55 +
sin(p.y * 0.36 - time * 1.05) * 0.45 +
sin((p.x + p.y) * 0.85 + time * 2.10) * 0.28 +
noise(p * 0.9 + time * 0.25) * 0.35;
float slope = shore * 0.35 + 0.06;
vec3 N = normalize(vec3(dFdx(wave) * 1.6, 1.0, dFdy(wave) * 1.6) + vNormal * slope);
N = normalize(mix(N, vNormal, shore * 0.4));
vec3 V = normalize(vCameraPos - vWorldPos);
vec3 L = normalize(ubo.lightDirIntensity.xyz);
vec3 H = normalize(V + L);
float ndl = max(dot(N, L), 0.0);
float ndv = max(dot(N, V), 0.001);
// Depth proxy: open water is deep, the shore band is shallow.
float depth = 1.0 - shore;
vec3 deep = vec3(0.012, 0.055, 0.105);
vec3 shallow = vec3(0.055, 0.30, 0.36);
vec3 albedo = mix(shallow, deep, smoothstep(0.15, 0.85, depth));
float fresnel = 0.02 + 0.98 * pow(1.0 - ndv, 5.0);
vec3 sky = mix(vec3(0.10, 0.20, 0.28), vec3(0.42, 0.62, 0.78), max(N.y, 0.0));
vec3 color = mix(albedo, sky, fresnel * 0.72);
color += ubo.lightColor.rgb * pow(max(dot(N, H), 0.0), 96.0) * 0.95;
color += albedo * ndl * ubo.lightColor.rgb * 0.35;
color += albedo * ubo.ambient.rgb;
// Foam rides the shoreline band and breaks up with noise.
float foamNoise = noise(p * 1.35 + vec2(time * 0.20, -time * 0.16));
float foam = smoothstep(0.55, 0.98, shore) * smoothstep(0.30, 0.75, foamNoise + shore * 0.35);
foam += smoothstep(0.90, 1.0, shore) * 0.55;
color = mix(color, vec3(0.90, 0.96, 0.98), clamp(foam, 0.0, 0.85));
float cloudField = noise(p * 0.02 + ubo.cloudWind.xy * time * 0.012);
color *= 1.0 - ubo.cloud.x * smoothstep(ubo.cloudWind.z - 0.15, ubo.cloudWind.z + 0.15, cloudField) * 0.30;
color = 1.0 - exp(-color * 1.35);
color = pow(max(color, vec3(0.0)), vec3(1.0 / 2.2));
float fog = smoothstep(240.0, 520.0, length(vViewPos));
color = mix(color, vec3(0.075, 0.11, 0.17), fog * 0.55);
outColor = vec4(color * vTint.rgb, 1.0);
}
+7
View File
@@ -0,0 +1,7 @@
config <- {
width = 1100
height = 720
title = "EVEngine Hex Terrain"
debug = false
hotReload = true
};
+7 -3
View File
@@ -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
};
+6 -5
View File
@@ -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
};
+53 -53
View File
@@ -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);
};
+43
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
config = {
title = "EVEngine Material Editor",
width = 1280,
height = 720,
debug = true,
hotReload = true,
};
+464
View File
@@ -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");
}
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

+45
View File
@@ -0,0 +1,45 @@
# Mesh Impact Lab(网格冲击形变)
把 Box3D 的**碰撞命中事件**接到网格形变会话上:飞行弹丸撞上静态目标,按命中点的法向
冲量对目标网格做一次性塑性冲击,重建并重新上传网格后立刻可见。整个示例没有任何输入,
启动即自动演示,用于验证“物理命中 → 网格损伤 → GPU 重新上传”这条链路。
## 运行
```bash
cd examples/mesh-impact-lab
../../build/linux-debug/src/engine/eve run
```
或:
```bash
make run/<platform>-debug GAME=examples/mesh-impact-lab
```
## 演示内容
1. `prototype.cylinder1` 配方生成目标网格,再经 `mesh.subdivide` + `mesh.weld` 修饰图
加密(细分 2 级、焊接容差 `1e-4`),作为可形变的目标。
2. `procgen.newMeshDeformationSession()` 持有该网格;`applyImpact()` 用命中点与法向
冲量做塑性凹陷,`currentMeshResult()` 取回快照并 `procgen.uploadMesh()` 上传。
3. 静态目标体(2.8×4.4×2.8)开启命中事件,动态球体弹丸在 X 方向以 12 m/s 射出。
4. 命中冲量超过 `0.05` 时应用损伤,并隐藏弹丸;随后在第 45 帧之后保存截图。
成功标记:
- 控制台打印 `MESH_IMPACT_LAB_PASS impulse=... colliderPolicy=deferred impact=bounded`
- 当前目录写出 `mesh-impact-lab.png`
## 文件
| 路径 | 说明 |
|---|---|
| `main.nut` | 全部逻辑:配方、修饰图、物理世界、命中消费与截图 |
| `config.nut` | 引擎 `config`:1100×700、`hotReload = true` |
| `mesh-impact-lab.png` | 期望产出的画面(示例自带参考图) |
## 契约
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
+58
View File
@@ -0,0 +1,58 @@
# Mesh Modifier Lab(网格修饰图实验室)
把同一份源网格(`prototype.cylinder1` 圆柱,高 4.0、细分 32)用 **13 种不同方式**变形后
并排渲染,用来对比“类型化修饰图(`MeshModifierGraph`)”“可交互形变会话
(`MeshDeformationSession`)”和“程序化变形节点”三条路径的覆盖面与结果。没有输入,
启动即静态展示,适合当作 API 对照表逐个查。
## 运行
```bash
cd examples/mesh-modifier-lab
../../build/linux-debug/src/engine/eve run
```
或:
```bash
make run/<platform>-debug GAME=examples/mesh-modifier-lab
```
## 13 个变体
| 变体 | 用的节点 / API |
|---|---|
| 源网格 | `mesh.input`(未变形基准) |
| Bend + Twist | `deform.bend`(Y 轴 28°)→ `deform.twist`(Y 轴 150°) |
| Noise + Spherify | `deform.noise`(幅度 0.28、seed 17)→ `deform.spherify` |
| FFD | `deform.ffd`(自由变形控制点) |
| Cut Plane | `mesh.cutPlane`(沿 Y 切平并封盖) |
| Spline Path | `mesh.subdivide` → `deform.splinePath`(Catmull-Rom 路径,按弧长缩放截面) |
| Sculpt | `MeshDeformationSession`:inflate 笔刷 + 方向笔刷 + 塑性冲击 |
| Effector | `deform.effector`(两点密度场) |
| Interactive Surface | `applySurfaceContact` + `recoverSurface`,以及 `configureColliderRefresh` / `updateColliderRefresh` / `requestColliderRefresh` 的 interval 与 manual 两种碰撞体替换路径 |
| Slime | `applySlimeImpulse` + `stepSlime`(弹簧-阻尼回弹) |
| Mesh Fit | `deform.transform` 作为目标面 → `deform.meshFit` 双向贴合 |
| Vertex Editor | `selectVerticesBox` → `moveSelectedVertices` / `manipulateSelectedVertices` |
| Sound React | `deform.soundReact`(电平 / 阈值 / 频率驱动变形) |
图编译同时验证了融合优化:控制台会打印编译后的段数与融合的操作数
(`getCompiledSegmentCount()` / `getFusedOperationCount()`)。
成功标记:
- 控制台打印 `MESH_MODIFIER_LAB_PASS variants=13 ...`
- 当前目录写出 `mesh-modifier-lab.png`
## 文件
| 路径 | 说明 |
|---|---|
| `main.nut` | 全部逻辑:源网格、13 个变体、相机、截图 |
| `config.nut` | 引擎 `config`:1280×720、`hotReload = true` |
| `mesh-modifier-lab.png` | 期望产出的画面(示例自带参考图) |
## 契约
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
+7 -4
View File
@@ -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.
+44
View File
@@ -0,0 +1,44 @@
# PCG Auto Depth of Field(自动景深对焦)
验证 **Pcg 自动对焦**这条纯脚本链路:脚本提供一条"视线命中距离"采样,引擎按设置
(最大距离、响应速度、跟踪模式)推进对焦状态机,输出的 `focusDistance` / `aperture`
再由脚本写回相机景深。示例同时打开 `gbuffer` 渲染通道,保证景深合成走 GPU 路径。
场景是一块地面加三根近景立方体,远处再放一排立方体作为虚化对象;对焦输入是固定
20.0 的命中距离(不依赖鼠标),因此无头运行也能得到确定结果。
## 运行
```bash
cd examples/pcg-depth-of-field
../../build/linux-debug/src/engine/eve run
```
或:
```bash
make run/<platform>-debug GAME=examples/pcg-depth-of-field
```
## 验证内容
- `eve.DepthOfFieldFocusSettings`(`tracking = 0` 手动、`maximumDistance = 100.0`、
`response = 3.5`)与 `eve.DepthOfFieldFocusInput`(`hasRayHit = true`、
`rayHitDistance = 20.0`、`deltaSeconds = 1.0`)驱动
`eve.evaluateDepthOfFieldFocus(state, output, settings, input)`;
- 返回结果 `!result.ok` 时直接 `throw`,不接受静默失败;
- 求得的 `focusDistance` / `aperture` 交给 `camera.setDepthOfField(focusDistance, 5.0, 12.0)`。
成功标记:`PCG_DEPTH_OF_FIELD_READY focus=... aperture=... gpuComposite=active`
## 文件
| 路径 | 说明 |
|---|---|
| `main.nut` | 场景搭建、对焦求值、写回相机景深、成功标记 |
| `config.nut` | 引擎 `config`:960×640、`debug = true`、`hotReload = true` |
## 契约
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
+1
View File
@@ -0,0 +1 @@
config={width=960,height=640,title="Pcg Auto Depth of Field",debug=true,hotReload=true};
+16
View File
@@ -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();};
+45
View File
@@ -0,0 +1,45 @@
# PCG Terrain Detail Overwrite(地形细节覆盖)
验证 **Pcg 细节覆盖**:脚本用 `PointSet` 描述一批草地点(位置、缩放、颜色、`asset`
字符串属性与稳定 Point ID),把程序化生成的 albedo / normal / mask 三张图烘焙成
`GrassField` 植被,再用 `setTerrainDetailOverwrite` 一次性覆盖地形的细节距离、淡出
距离、密度与每 patch 分辨率,并读回校验。整条链路不依赖任何下载资源或运行时 GLSL。
## 运行
```bash
cd examples/pcg-detail-overwrite
../../build/linux-debug/src/engine/eve run
```
或:
```bash
make run/<platform>-debug GAME=examples/pcg-detail-overwrite
```
## 验证内容
- 25×17 的草地点阵:逐点 `setScale` / `setColor` / `setStringAttribute(i, "asset", "pcg:detail")`,
最后 `assignPointIds("4242")` 固定 Point ID;
- 32×32 的 RGBA8 albedo / normal / mask 图像逐像素程序化生成(草叶 alpha 与 mask 通道);
- `eve.bakeTerrainGrassFoliage(field, points, "pcg:detail", 0.8, 1.25, albedo, normal, mask, foliage)`
上传植被,`GrassFoliageSettings` 给出基础色、alpha 裁剪、法线强度、渲染/淡出/硬距离与密度;
- `field.setTerrainDetailOverwrite(settings)` 覆盖细节参数:Pcg 细节距离 70 / 淡出 18,
Unity 细节距离 110 / 密度 0.55 / 每 patch 8;
- 断言返回质量档位为 `3`(High8)、`getTerrainDetailHardDistance() == 110.0`、
`getTerrainDetailDensity() == 0.55`,任一不符即 `throw`。
成功标记:`PCG_DETAIL_OVERWRITE_READY quality=High8 hard=110 density=0.55 fade=70+18 points=<点数>`
## 文件
| 路径 | 说明 |
|---|---|
| `main.nut` | 点阵构造、程序化贴图、植被烘焙、细节覆盖与断言 |
| `config.nut` | 引擎 `config`:960×640、`hotReload = true` |
## 契约
本示例是可运行示例(`main.nut` + `config.nut`),参与 `scripts/smoke_examples.sh` 的
帧循环契约:CI 在 Linux 上无头启动它,要求至少 2 秒仍然存活且日志中没有错误标记。
+6
View File
@@ -0,0 +1,6 @@
config = {
title = "Pcg Terrain Detail Overwrite",
width = 960,
height = 640,
hotReload = true,
}
+56
View File
@@ -0,0 +1,56 @@
local retained=[];
local field=null;
local camera=null;
function keep(value) { retained.append(value); return value; }
function need(result, label) {
if(!result.ok) throw "pcg-detail-overwrite: "+label+" failed";
return result.value;
}
eve_init <- function() {
gfx.setBackgroundColor(0.035,0.055,0.075,1.0);
gfx.setDirectionalLight(-0.45,0.85,0.35,1.6,1.48,1.25);
camera=keep(eve.Camera3D());
camera.setEye(0.0,5.2,11.0);camera.setTarget(0.0,1.0,0.0);camera.setUp(0.0,1.0,0.0);
camera.setFov(48.0);camera.setClipPlanes(0.1,100.0);camera.setAmbient(0.42,0.48,0.55);camera.setActive(true);
local points=keep(need(procgen.newPointSet(),"point set"));
for(local z=-8;z<=8;++z) for(local x=-12;x<=12;++x) {
local px=x*0.38+(z%2)*0.17,pz=z*0.42;
local i=points.add(px,0.0,pz);
local h=0.75+((x*x+z*z)%7)*0.055;
local w=0.7+((x+z+40)%5)*0.09;
points.setScale(i,w,h,w);points.setStringAttribute(i,"asset","pcg:detail");
points.setColor(i,0.48+((x+20)%4)*0.08,0.72+((z+20)%3)*0.07,0.28,1.0);
}
points.assignPointIds("4242");
local imageApi=eve.Image();
local albedo=keep(imageApi.newEmptyImageData(32,32,"RGBA8"));
local normal=keep(imageApi.newEmptyImageData(albedo.getWidth(),albedo.getHeight(),"RGBA8"));
local mask=keep(imageApi.newEmptyImageData(albedo.getWidth(),albedo.getHeight(),"RGBA8"));
for(local y=0;y<albedo.getHeight();++y) for(local x=0;x<albedo.getWidth();++x) {
local nx=(x-15.5)/15.5,ny=y/31.0;
local blade=abs(nx)<(0.08+0.34*ny);
albedo.setPixel(x,y,0.34+0.2*ny,0.72+0.2*ny,0.18,blade?1.0:0.0);
normal.setPixel(x,y,0.5,0.5,1.0,1.0);mask.setPixel(x,y,0.05,0.82,0.22,0.72);
}
field=keep(gfx.newGrassField());
local foliage=eve.GrassFoliageSettings();
foliage.baseR=0.9;foliage.baseG=1.0;foliage.baseB=0.82;foliage.alphaCutoff=0.08;
foliage.normalStrength=1.0;foliage.renderDistance=70.0;foliage.fadeRange=18.0;
foliage.hardRenderDistance=110.0;foliage.density=1.0;
foliage.snowMinimumHeight=100.0;foliage.snowFadeDistance=20.0;foliage.snowProgress=0.0;
need(eve.bakeTerrainGrassFoliage(field,points,"pcg:detail",0.8,1.25,albedo,normal,mask,foliage),"foliage upload");
local settings=eve.TerrainDetailOverwriteSettings();
settings.pcgDetailDistance=70.0;settings.pcgFadeoutDistance=18.0;
settings.unityDetailDistance=110;settings.unityDetailDensity=0.55;settings.detailResolutionPerPatch=8;
local quality=need(field.setTerrainDetailOverwrite(settings),"detail overwrite");
if(quality!=3 || field.getTerrainDetailHardDistance()!=110.0 || field.getTerrainDetailDensity()!=0.55)
throw "detail overwrite contract mismatch";
print("PCG_DETAIL_OVERWRITE_READY quality=High8 hard=110 density=0.55 fade=70+18 points="+points.getCount()+"\n");
};
eve_update <- function(dt) { if(field!=null) field.update(dt); };
eve_render <- function() { gfx.clear();gfx.render3D();if(field!=null) field.draw(); };
Loaded 100 of 883 files, more files were not shown because too many files have changed in this diff. Show more