Compare commits
10
Commits
5189bcd04f
...
a77086b518
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77086b518 | ||
|
|
b238fb5b19 | ||
|
|
cd8f221ddd | ||
|
|
faabfc2cb4 | ||
|
|
2d0168104d | ||
|
|
88e0afa46b | ||
|
|
c6d9ff1a26 | ||
|
|
dcadc57061 | ||
|
|
792b626396 | ||
|
|
c2bcb9b9fe |
@@ -0,0 +1,153 @@
|
||||
name: Build
|
||||
|
||||
# M4's "done when": the build runs on both platforms with no editor
|
||||
# plugins in the shipped binary. Neither half of that is verifiable from
|
||||
# a single Linux dev machine — this workflow is what actually proves it,
|
||||
# real builds on real ubuntu-latest and windows-latest runners, not just
|
||||
# a cross-platform-looking csproj and hope.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
rid: linux-x64
|
||||
- os: windows-latest
|
||||
rid: win-x64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 9.0.x
|
||||
|
||||
- name: Build native physics shim (Box3D, pinned commit — see native/physics-native/CMakeLists.txt)
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S native/physics-native -B native/physics-native/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/physics-native/build --config Release
|
||||
|
||||
- name: Build native audio shim (miniaudio)
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S native/audio-native -B native/audio-native/build -DCMAKE_BUILD_TYPE=Release
|
||||
cmake --build native/audio-native/build --config Release
|
||||
|
||||
# win-x64's .dll can only ever be produced here — nothing else in
|
||||
# this project can build or run one to check it. linux-x64's .so
|
||||
# IS also committed (for scripts/run-sample.sh's convenience on a
|
||||
# dev machine), but CI rebuilds and overwrites it fresh too, every
|
||||
# run: a real failure found by this exact workflow, not a
|
||||
# hypothetical one — the committed .so, built locally against a
|
||||
# newer glibc than ubuntu-latest ships, loaded fine here and threw
|
||||
# DllNotFoundException on the runner. Only a binary built on the
|
||||
# actual target platform is trustworthy there.
|
||||
- name: Place native libraries
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}"
|
||||
mkdir -p "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}"
|
||||
find native/physics-native/build -iname "liblingua_physics.so" -o -iname "lingua_physics.dll" \
|
||||
| xargs -I{} cp {} "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}/"
|
||||
find native/audio-native/build -iname "liblingua_audio.so" -o -iname "lingua_audio.dll" \
|
||||
| xargs -I{} cp {} "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}/"
|
||||
ls "plugins/engine.physics/Engine.Physics/native/${{ matrix.rid }}"
|
||||
ls "plugins/engine.audio/Engine.Audio/native/${{ matrix.rid }}"
|
||||
|
||||
- name: Build
|
||||
run: dotnet build LinguaEngine.sln -c Release
|
||||
|
||||
# Nothing in the suite opens a real GL window or a real audio device
|
||||
# (Engine.Audio.Tests forces miniaudio's null backend specifically so
|
||||
# this works) — the whole thing runs headless on both runners.
|
||||
- name: Test
|
||||
run: dotnet test LinguaEngine.sln -c Release --no-build
|
||||
|
||||
- name: Stage the shippable build (engine.editor deliberately excluded)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
DIST="dist/${{ matrix.rid }}"
|
||||
STAGE="$DIST/plugins"
|
||||
mkdir -p "$STAGE"
|
||||
|
||||
for id in engine.windowing engine.assets engine.render engine.input engine.physics engine.audio; do
|
||||
impl="$(awk -F. '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1' OFS=. <<<"$id")"
|
||||
src="plugins/$id/$impl/bin/Release/net9.0"
|
||||
mkdir -p "$STAGE/$id"
|
||||
cp -r "$src/." "$STAGE/$id/"
|
||||
cp "plugins/$id/plugin.json" "$STAGE/$id/"
|
||||
done
|
||||
|
||||
mkdir -p "$DIST/engine"
|
||||
cp -r src/Engine.Host/bin/Release/net9.0/. "$DIST/engine/"
|
||||
|
||||
GAME_DST="$DIST/samples/PhysicsDemo/GamePlugins/physics-demo-game"
|
||||
mkdir -p "$GAME_DST"
|
||||
cp -r samples/PhysicsDemo/GamePlugins/physics-demo-game/bin/Release/net9.0/. "$GAME_DST/"
|
||||
cp samples/PhysicsDemo/GamePlugins/physics-demo-game/plugin.json "$GAME_DST/"
|
||||
|
||||
cp -r samples/PhysicsDemo/assets "$DIST/samples/PhysicsDemo/assets"
|
||||
cp samples/PhysicsDemo/project.json samples/PhysicsDemo/scene.json "$DIST/samples/PhysicsDemo/"
|
||||
|
||||
# A deliberately minimal, engine.windowing/engine.render-free stage,
|
||||
# separate from the full one above: found the hard way that --headless
|
||||
# only controls whether Engine.Host's own loop pumps a window — it
|
||||
# does nothing to stop a *loaded* engine.windowing/engine.render from
|
||||
# creating a real window and GL context regardless, which the full
|
||||
# PhysicsDemo project always does. That's fine on a real desktop; on
|
||||
# windows-latest's GPU-less runner it's "WGL: The driver does not
|
||||
# appear to support OpenGL," failing before physics ever got to run.
|
||||
# engine.physics needs no display at all, so this checks only that.
|
||||
- name: Stage a minimal headless-safe build for verification
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERIFY="verify/${{ matrix.rid }}"
|
||||
mkdir -p "$VERIFY/plugins/engine.physics"
|
||||
cp -r plugins/engine.physics/Engine.Physics/bin/Release/net9.0/. "$VERIFY/plugins/engine.physics/"
|
||||
cp plugins/engine.physics/plugin.json "$VERIFY/plugins/engine.physics/"
|
||||
|
||||
mkdir -p "$VERIFY/engine"
|
||||
cp -r src/Engine.Host/bin/Release/net9.0/. "$VERIFY/engine/"
|
||||
|
||||
mkdir -p "$VERIFY/samples/PhysicsDemo"
|
||||
cp samples/PhysicsDemo/project.ci-headless.json "$VERIFY/samples/PhysicsDemo/project.json"
|
||||
cp samples/PhysicsDemo/scene.ci-headless.json "$VERIFY/samples/PhysicsDemo/scene.json"
|
||||
|
||||
- name: Verify the shippable build actually runs (headless, real physics + real scene load, real P/Invoke)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Absolute paths only: Assembly.LoadFromAssemblyPath (which
|
||||
# PluginHost.Load uses) throws on a relative one — found by
|
||||
# hand running this exact staging locally before trusting it to
|
||||
# a workflow run at all.
|
||||
VERIFY="$(pwd)/verify/${{ matrix.rid }}"
|
||||
cd "$VERIFY/samples/PhysicsDemo"
|
||||
dotnet "$VERIFY/engine/Engine.Host.dll" run --headless \
|
||||
--plugins "$VERIFY/plugins" --project project.json --scene scene.json \
|
||||
--frames 200 --dump dump.json
|
||||
cat dump.json
|
||||
python3 -c "
|
||||
import json
|
||||
data = json.load(open('dump.json'))
|
||||
box = next(o for o in data if o['name'] == 'DemoBox')
|
||||
y = box['transform']['position'][1]
|
||||
assert 0.9 < y < 1.1, f'DemoBox should have settled near y=1.0, got y={y}'
|
||||
print(f'DemoBox settled at y={y:.4f} — physics ran for real on ${{ matrix.rid }}')
|
||||
"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lingua-engine-${{ matrix.rid }}
|
||||
path: dist/${{ matrix.rid }}
|
||||
+23
@@ -18,7 +18,30 @@ Thumbs.db
|
||||
out/
|
||||
artifacts/
|
||||
|
||||
## scripts/stage-plugins.sh's output — regenerated from plugins/, not source.
|
||||
.stage/
|
||||
|
||||
## ImGui's own persisted window layout (position/size/open-state) — a
|
||||
## per-machine editing session preference, not project source, same
|
||||
## category as an IDE's own workspace state.
|
||||
imgui.ini
|
||||
|
||||
## CMake build output and FetchContent's cloned dependencies (Box3D) for
|
||||
## native/*-native/ — regenerated by cmake -S . -B build, not source; only
|
||||
## the built liblingua_physics.so (checked in under plugins/engine.physics/
|
||||
## Engine.Physics/native/) is what the engine actually ships.
|
||||
native/*/build/
|
||||
native/*/.fetchcontent-cache/
|
||||
|
||||
## scripts/run-physics-demo.sh copies the game-specific plugin's built
|
||||
## output directly into its own folder (that's where its own pluginPaths
|
||||
## entry in project.json points) — regenerated, not source. plugin.json,
|
||||
## the .csproj, and the .cs files there stay tracked.
|
||||
samples/*/GamePlugins/*/*.dll
|
||||
samples/*/GamePlugins/*/*.pdb
|
||||
|
||||
## Windows builds of the native physics/audio shims — nothing here can
|
||||
## build or verify one (no Windows machine), so unlike linux-x64's .so,
|
||||
## win-x64's .dll is never committed. .github/workflows/build.yml's
|
||||
## Windows job builds it fresh into this exact path before `dotnet build`.
|
||||
**/native/win-x64/
|
||||
|
||||
@@ -57,6 +57,38 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor", "plugins\en
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Editor.Tests", "tests\Engine.Editor.Tests\Engine.Editor.Tests.csproj", "{7F1CF468-6717-4152-BA95-2DA1E011B6CB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.physics", "engine.physics", "{5BFA9F87-CB7C-360F-A763-812D3AAB4167}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Contracts", "plugins\engine.physics\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj", "{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics", "plugins\engine.physics\Engine.Physics\Engine.Physics.csproj", "{A9205D28-762B-49C0-8CFF-8D59BA330786}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Physics.Tests", "tests\Engine.Physics.Tests\Engine.Physics.Tests.csproj", "{70245995-311C-4CBE-A433-F4035303BEDB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.audio", "engine.audio", "{2D4F0C3C-7532-0E50-D9FD-7209344A42BE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Contracts", "plugins\engine.audio\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj", "{2C2E545D-6937-476D-AC62-F96CEA6403F9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio", "plugins\engine.audio\Engine.Audio\Engine.Audio.csproj", "{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Audio.Tests", "tests\Engine.Audio.Tests\Engine.Audio.Tests.csproj", "{E610BD6A-A732-4863-8D19-555B75134E13}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PhysicsDemo", "PhysicsDemo", "{51874E45-60A1-E43B-395E-A3BE4050752F}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GamePlugins", "GamePlugins", "{9760813E-A577-3C56-DE20-0218A108BABD}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "physics-demo-game", "physics-demo-game", "{83FF5113-4091-0AE3-A9AD-50B7BB917258}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame", "samples\PhysicsDemo\GamePlugins\physics-demo-game\PhysicsDemoGame.csproj", "{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhysicsDemoGame.Tests", "tests\PhysicsDemoGame.Tests\PhysicsDemoGame.Tests.csproj", "{0201686F-0E2A-400F-8166-CBD11B65D724}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sandbox.failing-configure", "sandbox.failing-configure", "{AB8A31F6-ADF4-A607-5774-F0AC7E353102}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sandbox.FailingConfigure", "plugins\sandbox.failing-configure\Sandbox.FailingConfigure\Sandbox.FailingConfigure.csproj", "{428A0279-9B16-473D-92BC-858D4ABB15E2}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -283,6 +315,114 @@ Global
|
||||
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{7F1CF468-6717-4152-BA95-2DA1E011B6CB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x64.Build.0 = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D}.Release|x86.Build.0 = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x64.Build.0 = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786}.Release|x86.Build.0 = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x64.Build.0 = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724}.Release|x86.Build.0 = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x64.Build.0 = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -312,5 +452,20 @@ Global
|
||||
{6A2943F1-1984-470D-BD74-C6E16BB77241} = {8067C66D-9D49-192B-E4C1-1C024401ACE3}
|
||||
{B60A3933-923C-4339-98D1-E4C87A76E80B} = {8067C66D-9D49-192B-E4C1-1C024401ACE3}
|
||||
{7F1CF468-6717-4152-BA95-2DA1E011B6CB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{5BFA9F87-CB7C-360F-A763-812D3AAB4167} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{70BE8EA7-0EF5-4AA1-A6CD-79B7ED4C207D} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
|
||||
{A9205D28-762B-49C0-8CFF-8D59BA330786} = {5BFA9F87-CB7C-360F-A763-812D3AAB4167}
|
||||
{70245995-311C-4CBE-A433-F4035303BEDB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{2D4F0C3C-7532-0E50-D9FD-7209344A42BE} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{2C2E545D-6937-476D-AC62-F96CEA6403F9} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
{C9AA2FF3-EB6C-4849-AD9F-9E99EFF30AEB} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
{E610BD6A-A732-4863-8D19-555B75134E13} = {2D4F0C3C-7532-0E50-D9FD-7209344A42BE}
|
||||
{51874E45-60A1-E43B-395E-A3BE4050752F} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
|
||||
{9760813E-A577-3C56-DE20-0218A108BABD} = {51874E45-60A1-E43B-395E-A3BE4050752F}
|
||||
{83FF5113-4091-0AE3-A9AD-50B7BB917258} = {9760813E-A577-3C56-DE20-0218A108BABD}
|
||||
{1AFDA567-D86A-43C8-91B9-34CC13F48FEF} = {83FF5113-4091-0AE3-A9AD-50B7BB917258}
|
||||
{0201686F-0E2A-400F-8166-CBD11B65D724} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{AB8A31F6-ADF4-A607-5774-F0AC7E353102} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
|
||||
{428A0279-9B16-473D-92BC-858D4ABB15E2} = {AB8A31F6-ADF4-A607-5774-F0AC7E353102}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -116,8 +116,35 @@ and is unit-tested on its own — including the case a screenshot can't
|
||||
easily catch, dragging an object parented under a non-uniformly-scaled
|
||||
parent.
|
||||
|
||||
No physics yet — see the build order (M0–M4) in
|
||||
[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next.
|
||||
**M4 in progress — the "done when" is met.** `engine.physics` wraps Box3D
|
||||
and `engine.audio` wraps miniaudio, both through a deliberately narrow C
|
||||
shim — neither library's own config structs (large, with function
|
||||
pointers and, for Box3D, at least one type that "cannot be directly
|
||||
copied") cross the P/Invoke boundary, only plain scalars and handles do.
|
||||
`samples/PhysicsDemo` ties physics, audio, input, and rendering together
|
||||
in one running project with `engine.editor` deliberately left out of its
|
||||
`project.json` — the shippable configuration, not the dev one.
|
||||
`.github/workflows/build.yml` builds both native shims from source, runs
|
||||
the full test suite, and headless-verifies real physics end to end, on
|
||||
real `ubuntu-latest` and `windows-latest` runners — the only way to check
|
||||
the Windows half at all from a single Linux dev machine. **Both platforms
|
||||
are green.** The first real run caught two things no amount of local
|
||||
testing could have: the linux-x64 `.so` committed for local dev
|
||||
convenience, built against this dev machine's own newer glibc, didn't
|
||||
load on `ubuntu-latest`'s (CI now rebuilds it fresh every run instead of
|
||||
trusting a locally-built binary); and `--headless` doesn't stop a loaded
|
||||
`engine.windowing`/`engine.render` from creating a real GL context
|
||||
regardless, which fails outright on a GPU-less runner (CI's verification
|
||||
step now stages a separate, `engine.physics`-only build with no window
|
||||
plugin at all — `engine.render`'s own correctness is proven by real
|
||||
screenshots on a real desktop instead, not something a headless runner
|
||||
could check anyway).
|
||||
|
||||
Still open for M4: the "one small game" target is the physics demo so
|
||||
far, not yet a scored 20-minute one.
|
||||
|
||||
See the build order (M0–M4) in
|
||||
[`docs/kernel-contract.md`](docs/kernel-contract.md) for the rest.
|
||||
|
||||
Design and implementation are argued over in the same place: the doc is
|
||||
still the thing to disagree with before code changes to match.
|
||||
|
||||
@@ -53,8 +53,8 @@ nothing.
|
||||
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
|
||||
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
|
||||
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
|
||||
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. |
|
||||
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. |
|
||||
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; `engine.assets` publishes `TextureReloaded` when a watched file changes on disk (M2). Still nothing publishes `GameObject` created/destroyed facts — `GameWorld` doesn't touch the bus at all, deliberately: a publish on every structural change would tax the hot path for listeners that usually don't exist. |
|
||||
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`, `FixedDeltaTime`) and logging, both on `IPluginContext`. The fixed-step accumulator (`Time.ConsumeFixedSteps`) shipped with M4, driving `Stage.FixedUpdate` — `engine.physics` is its first real consumer. |
|
||||
|
||||
`GameObject.Transform` is the one field embedded directly rather than
|
||||
modeled as a `Component` subclass — it's a plain struct holding local
|
||||
@@ -436,7 +436,7 @@ cost of changing course is still zero.
|
||||
| **M1** | **Window, input, a triangle.** Three separate plugins over Silk.NET. First real-load test of the data channel. | The triangle's color changes by editing system code, with no app restart. |
|
||||
| **M2** | **Assets and scenes.** Hot-reloading asset plugin, scene format, `World` serialization. | Swapping a texture on disk changes the picture with nothing stopped; a scene loads and saves. |
|
||||
| **M3** | **Editor as plugins — done.** Shell (`engine.editor`, ImGui over the live scene, see §5 and the new `Stage.Present`), reflection-based inspector and hierarchy, Play/Stop on snapshots, a real 3D translate gizmo driven by the actual camera. | Entering Play takes under 100 ms — the original complaint about Unity is closed. Proven twice: `WorldSnapshotTests` (kernel, 300 `GameObject`s) and a real editor run against `samples/WindowDemo` (13ms, logged by `PlayModeController`). |
|
||||
| **M4** | **One small game, end to end.** `engine.physics` over Box3D, audio, a Linux + Windows build pipeline. A 20-minute game, shipped as an executable. | The build runs on both platforms with no editor plugins in the shipped binary. |
|
||||
| **M4** | **One small game, end to end — done when met, game itself in progress.** `engine.physics` (Box3D) and `engine.audio` (miniaudio) done, both over a narrow, scalars-only P/Invoke shim; `samples/PhysicsDemo` ties physics/audio/input/render together with `engine.editor` deliberately excluded from its `project.json`. `.github/workflows/build.yml` builds both native shims from source, tests, and headless-verifies on real `ubuntu-latest`/`windows-latest` runners — both green on the first real run, which itself caught two real cross-platform bugs no local testing could have (a locally-built `.so` too new for `ubuntu-latest`'s glibc; `--headless` not stopping a loaded `engine.render` from demanding a real GL context on a GPU-less Windows runner — see README's Status section). Not yet: the demo is a physics sandbox, not yet a scored 20-minute game. | The build runs on both platforms with no editor plugins in the shipped binary. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(lingua_audio_native C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
add_library(lingua_audio SHARED lingua_audio.c)
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(lingua_audio PRIVATE Threads::Threads m dl)
|
||||
endif()
|
||||
|
||||
install(TARGETS lingua_audio RUNTIME DESTINATION . LIBRARY DESTINATION .)
|
||||
@@ -0,0 +1,169 @@
|
||||
// The same narrow-shim approach as native/physics-native/lingua_physics.c,
|
||||
// for the same reason: miniaudio's own ma_engine_config/ma_sound_config are
|
||||
// large structs with optional callbacks, not something worth replicating
|
||||
// byte-for-byte in C#. Every exported function here takes and returns only
|
||||
// plain int/float/bool scalars (a C string for the one thing that has to
|
||||
// be one, a file path), and handles are this shim's own array-index
|
||||
// handles into a fixed-capacity ma_sound table — not miniaudio's own
|
||||
// pointer types.
|
||||
//
|
||||
// One global ma_engine: nothing in M4's scope needs more than one audio
|
||||
// device active at a time.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#define MINIAUDIO_IMPLEMENTATION
|
||||
#include "miniaudio.h"
|
||||
|
||||
#if defined( _WIN32 )
|
||||
#define LINGUA_API __declspec( dllexport )
|
||||
#else
|
||||
#define LINGUA_API __attribute__( ( visibility( "default" ) ) )
|
||||
#endif
|
||||
|
||||
#define LINGUA_MAX_SOUNDS 64
|
||||
|
||||
static ma_engine g_engine;
|
||||
static int g_engineInitialized = 0;
|
||||
|
||||
static ma_sound g_sounds[LINGUA_MAX_SOUNDS];
|
||||
static int g_soundUsed[LINGUA_MAX_SOUNDS];
|
||||
|
||||
static int ValidSound( int handle )
|
||||
{
|
||||
return handle >= 0 && handle < LINGUA_MAX_SOUNDS && g_soundUsed[handle];
|
||||
}
|
||||
|
||||
// useNullBackend: real playback needs a real device, which automated
|
||||
// tests shouldn't depend on (nondeterministic across machines, and CI
|
||||
// almost never has one) or want (nobody expects a test run to make sound).
|
||||
// The null backend runs the exact same mixing/decoding/looping pipeline
|
||||
// against a device that discards its output — real coverage of this
|
||||
// shim's logic, silently.
|
||||
LINGUA_API int Lingua_Audio_Init( int useNullBackend )
|
||||
{
|
||||
if ( g_engineInitialized )
|
||||
return 1;
|
||||
|
||||
ma_result result;
|
||||
|
||||
if ( useNullBackend )
|
||||
{
|
||||
static ma_context context;
|
||||
ma_backend backends[] = { ma_backend_null };
|
||||
|
||||
result = ma_context_init( backends, 1, NULL, &context );
|
||||
if ( result != MA_SUCCESS )
|
||||
return 0;
|
||||
|
||||
ma_engine_config engineConfig = ma_engine_config_init();
|
||||
engineConfig.pContext = &context;
|
||||
result = ma_engine_init( &engineConfig, &g_engine );
|
||||
}
|
||||
else
|
||||
{
|
||||
result = ma_engine_init( NULL, &g_engine );
|
||||
}
|
||||
|
||||
if ( result != MA_SUCCESS )
|
||||
return 0;
|
||||
|
||||
g_engineInitialized = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_Shutdown( void )
|
||||
{
|
||||
if ( !g_engineInitialized )
|
||||
return;
|
||||
|
||||
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
|
||||
{
|
||||
if ( g_soundUsed[i] )
|
||||
{
|
||||
ma_sound_uninit( &g_sounds[i] );
|
||||
g_soundUsed[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
ma_engine_uninit( &g_engine );
|
||||
g_engineInitialized = 0;
|
||||
}
|
||||
|
||||
LINGUA_API int Lingua_Audio_LoadSound( const char* path )
|
||||
{
|
||||
if ( !g_engineInitialized )
|
||||
return -1;
|
||||
|
||||
int slot = -1;
|
||||
for ( int i = 0; i < LINGUA_MAX_SOUNDS; i++ )
|
||||
{
|
||||
if ( !g_soundUsed[i] )
|
||||
{
|
||||
slot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ( slot < 0 )
|
||||
return -1;
|
||||
|
||||
if ( ma_sound_init_from_file( &g_engine, path, 0, NULL, NULL, &g_sounds[slot] ) != MA_SUCCESS )
|
||||
return -1;
|
||||
|
||||
g_soundUsed[slot] = 1;
|
||||
return slot;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_UnloadSound( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_uninit( &g_sounds[handle] );
|
||||
g_soundUsed[handle] = 0;
|
||||
}
|
||||
|
||||
// Always restarts from the first frame — the predictable behavior for
|
||||
// "trigger this sound effect now." A looping music track only needs this
|
||||
// called once; calling it again mid-loop is a deliberate restart, not a
|
||||
// bug.
|
||||
LINGUA_API void Lingua_Audio_Play( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_seek_to_pcm_frame( &g_sounds[handle], 0 );
|
||||
ma_sound_start( &g_sounds[handle] );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_Stop( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_stop( &g_sounds[handle] );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_SetVolume( int handle, float volume )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_set_volume( &g_sounds[handle], volume );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_Audio_SetLooping( int handle, bool loop )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return;
|
||||
|
||||
ma_sound_set_looping( &g_sounds[handle], loop ? MA_TRUE : MA_FALSE );
|
||||
}
|
||||
|
||||
LINGUA_API bool Lingua_Audio_IsPlaying( int handle )
|
||||
{
|
||||
if ( !ValidSound( handle ) )
|
||||
return false;
|
||||
|
||||
return ma_sound_is_playing( &g_sounds[handle] ) ? true : false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(lingua_physics_native C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
# Box3D built static and folded into our own shared library, so the engine
|
||||
# ships and P/Invokes exactly one native file (lingua_physics.so/.dll), not
|
||||
# two. Pinned to a specific commit, not a tag or branch: Box3D has no 1.0
|
||||
# release yet and its API is still moving (confirmed by diffing its only
|
||||
# tag, v0.1.0, against this commit — real, not hypothetical, drift), so a
|
||||
# floating reference would silently start building against a different API
|
||||
# than the one this shim's code was actually written and verified against.
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_SAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_UNIT_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_BENCHMARKS OFF CACHE BOOL "" FORCE)
|
||||
set(BOX3D_DOCS OFF CACHE BOOL "" FORCE)
|
||||
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
box3d
|
||||
GIT_REPOSITORY https://github.com/erincatto/box3d.git
|
||||
GIT_TAG 47d7f7cc7e091142c08d11dc7d2e493c5d34f536
|
||||
)
|
||||
FetchContent_MakeAvailable(box3d)
|
||||
|
||||
add_library(lingua_physics SHARED lingua_physics.c)
|
||||
target_link_libraries(lingua_physics PRIVATE box3d)
|
||||
|
||||
install(TARGETS lingua_physics RUNTIME DESTINATION . LIBRARY DESTINATION .)
|
||||
@@ -0,0 +1,243 @@
|
||||
// A deliberately narrow C shim between Box3D's real API and P/Invoke.
|
||||
//
|
||||
// Box3D's own API is not safe to bind directly from C#: b3WorldDef,
|
||||
// b3BodyDef, and b3ShapeDef are large structs with function pointers and
|
||||
// nested types passed by value, and b3BoxHull is a struct whose own doc
|
||||
// comment says "has data hanging off the end and cannot be directly
|
||||
// copied." Replicating any of that layout byte-for-byte in C# is exactly
|
||||
// the kind of "compiles, passes a smoke test, breaks on someone else's
|
||||
// machine" risk docs/kernel-contract.md's own risk table warns about for
|
||||
// native interop specifically. So none of it crosses the P/Invoke
|
||||
// boundary: every exported function here takes and returns only plain
|
||||
// int32_t/float scalars, which marshal automatically with no `unsafe` on
|
||||
// the C# side at all.
|
||||
//
|
||||
// Handles are plain array indices into fixed-capacity tables, not Box3D's
|
||||
// own id structs — one less thing to get the marshaling of exactly right,
|
||||
// and plenty of headroom for an indie-scale game (Box3D itself caps out at
|
||||
// 128 worlds; nothing here needs more than one at a time yet).
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <box3d/box3d.h>
|
||||
|
||||
#if defined( _WIN32 )
|
||||
#define LINGUA_API __declspec( dllexport )
|
||||
#else
|
||||
#define LINGUA_API __attribute__( ( visibility( "default" ) ) )
|
||||
#endif
|
||||
|
||||
#define LINGUA_MAX_WORLDS 8
|
||||
#define LINGUA_MAX_BODIES 8192
|
||||
|
||||
static b3WorldId g_worlds[LINGUA_MAX_WORLDS];
|
||||
static bool g_worldUsed[LINGUA_MAX_WORLDS];
|
||||
|
||||
static b3BodyId g_bodies[LINGUA_MAX_BODIES];
|
||||
static bool g_bodyUsed[LINGUA_MAX_BODIES];
|
||||
|
||||
static bool ValidWorld( int32_t handle )
|
||||
{
|
||||
return handle >= 0 && handle < LINGUA_MAX_WORLDS && g_worldUsed[handle];
|
||||
}
|
||||
|
||||
static bool ValidBody( int32_t handle )
|
||||
{
|
||||
return handle >= 0 && handle < LINGUA_MAX_BODIES && g_bodyUsed[handle];
|
||||
}
|
||||
|
||||
static int32_t AllocBodySlot( void )
|
||||
{
|
||||
for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ )
|
||||
{
|
||||
if ( !g_bodyUsed[i] )
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
LINGUA_API int32_t Lingua_CreateWorld( float gravityX, float gravityY, float gravityZ )
|
||||
{
|
||||
for ( int32_t i = 0; i < LINGUA_MAX_WORLDS; i++ )
|
||||
{
|
||||
if ( g_worldUsed[i] )
|
||||
continue;
|
||||
|
||||
b3WorldDef def = b3DefaultWorldDef();
|
||||
def.gravity = ( b3Vec3 ){ gravityX, gravityY, gravityZ };
|
||||
|
||||
g_worlds[i] = b3CreateWorld( &def );
|
||||
g_worldUsed[i] = true;
|
||||
return i;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_DestroyWorld( int32_t worldHandle )
|
||||
{
|
||||
if ( !ValidWorld( worldHandle ) )
|
||||
return;
|
||||
|
||||
b3DestroyWorld( g_worlds[worldHandle] );
|
||||
g_worldUsed[worldHandle] = false;
|
||||
}
|
||||
|
||||
// Exists so C# can assert on the native side's own bookkeeping directly —
|
||||
// PhysicsWorld's C#-side _bodies dictionary can look correct while the
|
||||
// native table underneath it has leaked a body Sync should have destroyed
|
||||
// (see PhysicsWorldTests for the regression this catches).
|
||||
LINGUA_API int32_t Lingua_GetBodyCount( void )
|
||||
{
|
||||
int32_t count = 0;
|
||||
for ( int32_t i = 0; i < LINGUA_MAX_BODIES; i++ )
|
||||
{
|
||||
if ( g_bodyUsed[i] )
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_WorldStep( int32_t worldHandle, float timeStep, int32_t subStepCount )
|
||||
{
|
||||
if ( !ValidWorld( worldHandle ) )
|
||||
return;
|
||||
|
||||
b3World_Step( g_worlds[worldHandle], timeStep, subStepCount );
|
||||
}
|
||||
|
||||
static int32_t CreateBody(
|
||||
int32_t worldHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw,
|
||||
int32_t bodyType )
|
||||
{
|
||||
if ( !ValidWorld( worldHandle ) )
|
||||
return -1;
|
||||
|
||||
int32_t slot = AllocBodySlot();
|
||||
if ( slot < 0 )
|
||||
return -1;
|
||||
|
||||
b3BodyDef bodyDef = b3DefaultBodyDef();
|
||||
bodyDef.type = (b3BodyType)bodyType;
|
||||
bodyDef.position = ( b3Vec3 ){ px, py, pz };
|
||||
bodyDef.rotation = ( b3Quat ){ { qx, qy, qz }, qw };
|
||||
|
||||
g_bodies[slot] = b3CreateBody( g_worlds[worldHandle], &bodyDef );
|
||||
g_bodyUsed[slot] = true;
|
||||
return slot;
|
||||
}
|
||||
|
||||
static void ShapeDef( float density, float friction, float restitution, b3ShapeDef* def )
|
||||
{
|
||||
*def = b3DefaultShapeDef();
|
||||
def->density = density;
|
||||
def->baseMaterial.friction = friction;
|
||||
def->baseMaterial.restitution = restitution;
|
||||
}
|
||||
|
||||
LINGUA_API int32_t Lingua_CreateBoxBody(
|
||||
int32_t worldHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw,
|
||||
float halfWidth, float halfHeight, float halfDepth,
|
||||
int32_t bodyType, float density, float friction, float restitution )
|
||||
{
|
||||
int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType );
|
||||
if ( handle < 0 )
|
||||
return -1;
|
||||
|
||||
b3ShapeDef shapeDef;
|
||||
ShapeDef( density, friction, restitution, &shapeDef );
|
||||
|
||||
b3BoxHull hull = b3MakeBoxHull( halfWidth, halfHeight, halfDepth );
|
||||
b3CreateHullShape( g_bodies[handle], &shapeDef, &hull.base );
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
LINGUA_API int32_t Lingua_CreateSphereBody(
|
||||
int32_t worldHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw,
|
||||
float radius,
|
||||
int32_t bodyType, float density, float friction, float restitution )
|
||||
{
|
||||
int32_t handle = CreateBody( worldHandle, px, py, pz, qx, qy, qz, qw, bodyType );
|
||||
if ( handle < 0 )
|
||||
return -1;
|
||||
|
||||
b3ShapeDef shapeDef;
|
||||
ShapeDef( density, friction, restitution, &shapeDef );
|
||||
|
||||
b3Sphere sphere = { ( b3Vec3 ){ 0, 0, 0 }, radius };
|
||||
b3CreateSphereShape( g_bodies[handle], &shapeDef, &sphere );
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_DestroyBody( int32_t bodyHandle )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3DestroyBody( g_bodies[bodyHandle] );
|
||||
g_bodyUsed[bodyHandle] = false;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_GetBodyTransform(
|
||||
int32_t bodyHandle,
|
||||
float* outPx, float* outPy, float* outPz,
|
||||
float* outQx, float* outQy, float* outQz, float* outQw )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3WorldTransform t = b3Body_GetTransform( g_bodies[bodyHandle] );
|
||||
*outPx = t.p.x;
|
||||
*outPy = t.p.y;
|
||||
*outPz = t.p.z;
|
||||
*outQx = t.q.v.x;
|
||||
*outQy = t.q.v.y;
|
||||
*outQz = t.q.v.z;
|
||||
*outQw = t.q.s;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_SetBodyTransform(
|
||||
int32_t bodyHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3Body_SetTransform( g_bodies[bodyHandle], ( b3Pos ){ px, py, pz }, ( b3Quat ){ { qx, qy, qz }, qw } );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_ApplyLinearImpulse( int32_t bodyHandle, float ix, float iy, float iz, bool wake )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3Body_ApplyLinearImpulseToCenter( g_bodies[bodyHandle], ( b3Vec3 ){ ix, iy, iz }, wake );
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_GetLinearVelocity( int32_t bodyHandle, float* outVx, float* outVy, float* outVz )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3Vec3 v = b3Body_GetLinearVelocity( g_bodies[bodyHandle] );
|
||||
*outVx = v.x;
|
||||
*outVy = v.y;
|
||||
*outVz = v.z;
|
||||
}
|
||||
|
||||
LINGUA_API void Lingua_SetLinearVelocity( int32_t bodyHandle, float vx, float vy, float vz )
|
||||
{
|
||||
if ( !ValidBody( bodyHandle ) )
|
||||
return;
|
||||
|
||||
b3Body_SetLinearVelocity( g_bodies[bodyHandle], ( b3Vec3 ){ vx, vy, vz } );
|
||||
}
|
||||
@@ -84,6 +84,16 @@ internal sealed class AssetService(IEventBus events, ILogger log) : IAssetServic
|
||||
{
|
||||
await Task.Delay(50);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
// The 5th and final attempt — the `when` guard above only
|
||||
// covers attempts 0-3, so this is the one case that used
|
||||
// to fall out of the loop and propagate from a
|
||||
// fire-and-forget Task (`_ = Task.Run(...)`) as an
|
||||
// unobserved exception: no log, no event, nothing to show
|
||||
// the reload silently never happened.
|
||||
log.Warn($"Giving up reloading '{path}' after 5 attempts: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A sound clip attached to a GameObject. ClipPath is loaded lazily, the
|
||||
/// first time engine.audio sees this component — not eagerly on
|
||||
/// AddComponent, since Component construction has no plugin context to
|
||||
/// load anything through (same reason texture loading in engine.render
|
||||
/// happens in a system, not a component constructor).
|
||||
/// </summary>
|
||||
public sealed class AudioSource : Component
|
||||
{
|
||||
public string ClipPath = "";
|
||||
public float Volume = 1f;
|
||||
public bool Loop = false;
|
||||
public bool PlayOnAwake = false;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Gameplay-facing trigger for a GameObject's AudioSource — "play the
|
||||
/// bounce sound now," not a field on AudioSource itself, same split
|
||||
/// IPhysicsService makes between a Rigidbody's persistent data and an
|
||||
/// action performed on it (see that interface's own doc comment).
|
||||
/// </summary>
|
||||
public interface IAudioService
|
||||
{
|
||||
void Play(GameObject go);
|
||||
|
||||
void Stop(GameObject go);
|
||||
|
||||
bool IsPlaying(GameObject go);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Engine.Audio.Tests")]
|
||||
@@ -0,0 +1,38 @@
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// M4's audio plugin: miniaudio, over the shim in native/audio-native/ —
|
||||
/// see that file's own doc comment for why nothing but scalars and a UTF-8
|
||||
/// path string cross the P/Invoke boundary. One system, on Stage.Update
|
||||
/// (not FixedUpdate — nothing about audio is physics-timed): sync new/
|
||||
/// changed/removed AudioSources every frame.
|
||||
/// </summary>
|
||||
public sealed class AudioPlugin : IPlugin
|
||||
{
|
||||
private AudioWorld? _world;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_world = new AudioWorld(ctx.Log, useNullBackend: false);
|
||||
ctx.Services.Provide<IAudioService>(new AudioService(_world));
|
||||
|
||||
ctx.Schedule.Add(Stage.Update, Sync).Reads<AudioSource>();
|
||||
|
||||
ctx.Log.Info("audio engine ready (miniaudio)");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("engine.audio");
|
||||
ctx.Services.Revoke<IAudioService>();
|
||||
_world?.Dispose();
|
||||
_world = null;
|
||||
}
|
||||
|
||||
private void Sync(IWorld world) => _world!.Sync(world);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
internal sealed class AudioService(AudioWorld world) : IAudioService
|
||||
{
|
||||
public void Play(GameObject go) => world.Play(go);
|
||||
|
||||
public void Stop(GameObject go) => world.Stop(go);
|
||||
|
||||
public bool IsPlaying(GameObject go) => world.IsPlaying(go);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the one native miniaudio engine this plugin creates, and the
|
||||
/// GameObject <-> loaded-sound handle mapping. <see cref="Sync"/> loads a
|
||||
/// clip the first time it sees a GameObject's AudioSource, applies
|
||||
/// Volume/Loop changes without re-touching the native side when nothing
|
||||
/// changed, fires PlayOnAwake exactly once, and — same no-destruction-event
|
||||
/// problem PhysicsWorld has — unloads sounds for GameObjects that no
|
||||
/// longer have an AudioSource.
|
||||
///
|
||||
/// useNullBackend exists for exactly one reason: Engine.Audio.Tests uses
|
||||
/// it. Real gameplay always gets a real device (see AudioPlugin) — nothing
|
||||
/// about the null backend belongs in a shipped build's own choice.
|
||||
/// </summary>
|
||||
internal sealed class AudioWorld : IDisposable
|
||||
{
|
||||
private readonly ILogger _log;
|
||||
private readonly Dictionary<GameObject, int> _handles = [];
|
||||
private readonly Dictionary<GameObject, (float Volume, bool Loop)> _lastSynced = [];
|
||||
private readonly HashSet<GameObject> _warnedFailedToLoad = [];
|
||||
|
||||
public AudioWorld(ILogger log, bool useNullBackend)
|
||||
{
|
||||
_log = log;
|
||||
|
||||
if (Native.Lingua_Audio_Init(useNullBackend ? 1 : 0) == 0)
|
||||
_log.Error("Failed to initialize the audio engine — no sounds will play.");
|
||||
}
|
||||
|
||||
public void Sync(IWorld world)
|
||||
{
|
||||
var live = new HashSet<GameObject>();
|
||||
|
||||
foreach (var go in world.Query<AudioSource>())
|
||||
{
|
||||
live.Add(go);
|
||||
var src = go.GetComponent<AudioSource>()!;
|
||||
|
||||
if (_warnedFailedToLoad.Contains(go))
|
||||
continue;
|
||||
|
||||
if (!_handles.TryGetValue(go, out var handle))
|
||||
{
|
||||
handle = Native.Lingua_Audio_LoadSound(src.ClipPath);
|
||||
if (handle < 0)
|
||||
{
|
||||
_log.Warn($"'{go.Name}' AudioSource failed to load '{src.ClipPath}'.");
|
||||
_warnedFailedToLoad.Add(go);
|
||||
continue;
|
||||
}
|
||||
|
||||
_handles[go] = handle;
|
||||
Native.Lingua_Audio_SetVolume(handle, src.Volume);
|
||||
Native.Lingua_Audio_SetLooping(handle, src.Loop);
|
||||
_lastSynced[go] = (src.Volume, src.Loop);
|
||||
|
||||
if (src.PlayOnAwake)
|
||||
Native.Lingua_Audio_Play(handle);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var last = _lastSynced[go];
|
||||
if (last.Volume != src.Volume)
|
||||
Native.Lingua_Audio_SetVolume(handle, src.Volume);
|
||||
if (last.Loop != src.Loop)
|
||||
Native.Lingua_Audio_SetLooping(handle, src.Loop);
|
||||
|
||||
_lastSynced[go] = (src.Volume, src.Loop);
|
||||
}
|
||||
|
||||
foreach (var stale in _handles.Keys.Where(go => !live.Contains(go)).ToList())
|
||||
{
|
||||
Native.Lingua_Audio_UnloadSound(_handles[stale]);
|
||||
_handles.Remove(stale);
|
||||
_lastSynced.Remove(stale);
|
||||
}
|
||||
|
||||
_warnedFailedToLoad.RemoveWhere(go => !live.Contains(go));
|
||||
}
|
||||
|
||||
public void Play(GameObject go)
|
||||
{
|
||||
if (_handles.TryGetValue(go, out var handle))
|
||||
Native.Lingua_Audio_Play(handle);
|
||||
}
|
||||
|
||||
public void Stop(GameObject go)
|
||||
{
|
||||
if (_handles.TryGetValue(go, out var handle))
|
||||
Native.Lingua_Audio_Stop(handle);
|
||||
}
|
||||
|
||||
public bool IsPlaying(GameObject go) =>
|
||||
_handles.TryGetValue(go, out var handle) && Native.Lingua_Audio_IsPlaying(handle);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var handle in _handles.Values)
|
||||
Native.Lingua_Audio_UnloadSound(handle);
|
||||
|
||||
_handles.Clear();
|
||||
_lastSynced.Clear();
|
||||
Native.Lingua_Audio_Shutdown();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Same reasoning as engine.physics' own native Content item — see
|
||||
there for why linux-x64 is committed and win-x64 isn't. -->
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<Content Include="native/linux-x64/liblingua_audio.so">
|
||||
<Link>liblingua_audio.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<Content Include="native/win-x64/lingua_audio.dll">
|
||||
<Link>lingua_audio.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Engine.Audio.Contracts\Engine.Audio.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Engine.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Matches native/audio-native/lingua_audio.c's exports one-to-one. Same
|
||||
/// choice as engine.physics' Native.cs: classic DllImport, not
|
||||
/// LibraryImport, so no AllowUnsafeBlocks is needed anywhere in this
|
||||
/// plugin — every parameter here is a plain scalar or a UTF-8 string,
|
||||
/// nothing that needs unsafe marshalling code.
|
||||
/// </summary>
|
||||
internal static class Native
|
||||
{
|
||||
private const string Lib = "lingua_audio";
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_Audio_Init(int useNullBackend);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Shutdown();
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_Audio_LoadSound([MarshalAs(UnmanagedType.LPUTF8Str)] string path);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_UnloadSound(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Play(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_Stop(int handle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_SetVolume(int handle, float volume);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_Audio_SetLooping(int handle, [MarshalAs(UnmanagedType.U1)] bool loop);
|
||||
|
||||
[DllImport(Lib)]
|
||||
[return: MarshalAs(UnmanagedType.U1)]
|
||||
public static extern bool Lingua_Audio_IsPlaying(int handle);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "engine.audio",
|
||||
"version": "0.1.0",
|
||||
"contracts": "Engine.Audio.Contracts.dll",
|
||||
"assembly": "Engine.Audio.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class EditorPlugin : IPlugin
|
||||
private PlayModeController? _playMode;
|
||||
private IEngineWindow? _window;
|
||||
private ICameraService? _camera;
|
||||
private bool _wasPlaying;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
@@ -107,10 +108,50 @@ public sealed class EditorPlugin : IPlugin
|
||||
ImGui.Text(_playMode.IsPlaying ? "(Playing)" : "(Edit mode)");
|
||||
ImGui.End();
|
||||
|
||||
// Found by independent review: ExitPlay's Restore destroys every
|
||||
// GameObject and rebuilds fresh instances from the snapshot, but
|
||||
// EditorState.Selected kept pointing at the old, now-detached one
|
||||
// — Inspector and the gizmo would silently keep editing an object
|
||||
// no longer in the world. Comparing against last frame's own
|
||||
// IsPlaying (not just reacting to the button above) catches this
|
||||
// uniformly regardless of *how* Play exited — the Stop button
|
||||
// here, or the "stop" stdin command Engine.Host already processed
|
||||
// before this Render-stage system ran this frame; the button
|
||||
// alone would miss the second path entirely, the same
|
||||
// stdin-vs-real-control gap already hit once this session.
|
||||
if (_wasPlaying && !_playMode.IsPlaying)
|
||||
{
|
||||
var previousName = _state.Selected?.Name;
|
||||
_state.Selected = previousName is null ? null : FindByName(world.Roots, previousName);
|
||||
}
|
||||
_wasPlaying = _playMode.IsPlaying;
|
||||
|
||||
HierarchyPanel.Draw(world, _state);
|
||||
InspectorPanel.Draw(_state);
|
||||
_gizmo.Draw(_state, _camera!, _window!);
|
||||
|
||||
_controller.Render();
|
||||
}
|
||||
|
||||
// Best-effort by name, not identity — Restore rebuilds fresh
|
||||
// GameObject instances, so there's no identity to match against
|
||||
// anymore. Two siblings sharing a name (nothing stops it, same caveat
|
||||
// HierarchyPanel's own PushID-by-hashcode already documents) means
|
||||
// this picks the first match, not necessarily "the same one" — an
|
||||
// acceptable approximation for reselecting after Play, not a
|
||||
// guarantee.
|
||||
private static GameObject? FindByName(IReadOnlyList<GameObject> roots, string name)
|
||||
{
|
||||
foreach (var go in roots)
|
||||
{
|
||||
if (go.Name == name)
|
||||
return go;
|
||||
|
||||
var found = FindByName(go.Children, name);
|
||||
if (found is not null)
|
||||
return found;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ namespace Engine.Editor;
|
||||
/// Hierarchy panel last clicked, so the Inspector panel (built against this
|
||||
/// same instance) knows what to show. One instance per EditorPlugin, not
|
||||
/// static: reloading engine.editor should start with nothing selected, not
|
||||
/// hold a reference to a GameObject that may not even exist anymore.
|
||||
/// hold a reference to a GameObject that may not even exist anymore. The
|
||||
/// same staleness can happen without a reload too — see EditorPlugin.
|
||||
/// DrawUi's IsPlaying-transition check, which re-resolves this after
|
||||
/// ExitPlay's Restore replaces every GameObject with a fresh instance.
|
||||
/// </summary>
|
||||
internal sealed class EditorState
|
||||
{
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Engine.Physics.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Values match Box3D's own b3BodyType exactly (Static=0, Kinematic=1,
|
||||
/// Dynamic=2) so engine.physics can cast this straight into the native
|
||||
/// shim's bodyType parameter with no translation table to keep in sync.
|
||||
/// </summary>
|
||||
public enum BodyType
|
||||
{
|
||||
Static = 0,
|
||||
Kinematic = 1,
|
||||
Dynamic = 2,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Physics.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A box collision shape, sized in half-extents (distance from center to
|
||||
/// each face) — Box3D's own convention (b3MakeBoxHull's hx/hy/hz), kept
|
||||
/// as-is rather than doubled into a "full size" field so there's no /2
|
||||
/// conversion to get subtly wrong between here and the native shim.
|
||||
/// </summary>
|
||||
public sealed class BoxCollider : Component
|
||||
{
|
||||
public Vector3 HalfExtents = new(0.5f, 0.5f, 0.5f);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Physics.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Gameplay-facing physics actions — impulses and velocity — for a
|
||||
/// GameObject that already has a Rigidbody. Separate from just poking
|
||||
/// Rigidbody's own fields because these aren't state the Inspector should
|
||||
/// show as persistent data (a velocity is a live simulation value, not
|
||||
/// something a scene file should round-trip) and because applying an
|
||||
/// impulse isn't a field write at all, it's an action Box3D performs.
|
||||
/// </summary>
|
||||
public interface IPhysicsService
|
||||
{
|
||||
void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true);
|
||||
|
||||
Vector3 GetLinearVelocity(GameObject go);
|
||||
|
||||
void SetLinearVelocity(GameObject go, Vector3 velocity);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Physics.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a GameObject as physics-simulated. Needs a BoxCollider or
|
||||
/// SphereCollider on the same GameObject to actually get a body — Rigidbody
|
||||
/// alone describes how it moves, not what shape it collides as, same split
|
||||
/// Box3D itself makes between a body and its shapes.
|
||||
///
|
||||
/// Physics-enabled GameObjects must be root-level (no parent) for now:
|
||||
/// PhysicsWorld writes Box3D's world-space transform straight into
|
||||
/// Transform.LocalPosition/LocalRotation, which is only correct when local
|
||||
/// and world space are the same thing. A parented rigidbody would need the
|
||||
/// same parent-WorldMatrix-inverse handling GizmoMath.WorldToLocalPosition
|
||||
/// already does for the gizmo — real work, not done here because nothing
|
||||
/// in M4's demo needs a physics object with a parent yet.
|
||||
/// </summary>
|
||||
public sealed class Rigidbody : Component
|
||||
{
|
||||
public BodyType Type = BodyType.Dynamic;
|
||||
public float Density = 1f;
|
||||
public float Friction = 0.5f;
|
||||
public float Restitution = 0.1f;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Physics.Contracts;
|
||||
|
||||
public sealed class SphereCollider : Component
|
||||
{
|
||||
public float Radius = 0.5f;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// PhysicsWorld is internal — nothing outside this plugin needs it, real
|
||||
// physics behavior is tested against it directly rather than only through
|
||||
// IPlugin/IPhysicsService's much narrower surface. Same pattern as
|
||||
// Engine.Assets' PngReader.
|
||||
[assembly: InternalsVisibleTo("Engine.Physics.Tests")]
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Generates the deps.json AssemblyDependencyResolver needs to find
|
||||
this plugin's dependencies — see PluginLoadContext in Engine.Kernel,
|
||||
and the same property on the other native-facing plugins. -->
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- The compiled native shim (Box3D folded in) — see
|
||||
native/physics-native/. Copied straight into this plugin's own
|
||||
output directory (not a runtimes/<rid>/native/ NuGet-style path):
|
||||
DllImport's default unmanaged-library probing already checks the
|
||||
calling assembly's own directory, and PluginLoadContext.
|
||||
LoadUnmanagedDll falls back to that default probing whenever its
|
||||
AssemblyDependencyResolver doesn't recognize the name (returning
|
||||
IntPtr.Zero, not failing outright) — confirmed empirically, see
|
||||
PhysicsWorldTests.
|
||||
|
||||
Picked by the BUILDING machine's OS, not cross-compiled: linux-x64's
|
||||
.so is committed (built and verified here); win-x64's .dll is never
|
||||
committed — nothing here can build or run one to verify — and is
|
||||
instead produced fresh by .github/workflows/build.yml's Windows job
|
||||
right before `dotnet build` runs, landing at exactly this path. -->
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<Content Include="native/linux-x64/liblingua_physics.so">
|
||||
<Link>liblingua_physics.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<Content Include="native/win-x64/lingua_physics.dll">
|
||||
<Link>lingua_physics.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\Engine.Physics.Contracts\Engine.Physics.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Engine.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// The entire P/Invoke surface, matching native/physics-native/
|
||||
/// lingua_physics.c's exports one-to-one. Every parameter is a plain
|
||||
/// int/float/bool — no struct crosses this boundary, on purpose, see that
|
||||
/// file's own doc comment for why.
|
||||
///
|
||||
/// Classic DllImport, not the newer source-generated LibraryImport:
|
||||
/// LibraryImport's generated marshalling code needs AllowUnsafeBlocks even
|
||||
/// for an all-blittable signature like every one of these, and there's no
|
||||
/// reason to open that door — DllImport marshals plain int/float/bool/out
|
||||
/// float scalars with no unsafe code at all, which is what actually keeps
|
||||
/// this plugin inside the kernel's "no unsafe in the v1 hot path" rule
|
||||
/// (Directory.Build.props) instead of needing a narrow exception to it.
|
||||
/// </summary>
|
||||
internal static class Native
|
||||
{
|
||||
private const string Lib = "lingua_physics";
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_CreateWorld(float gravityX, float gravityY, float gravityZ);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_DestroyWorld(int worldHandle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_WorldStep(int worldHandle, float timeStep, int subStepCount);
|
||||
|
||||
/// <summary>Total live bodies across every world — a test-only escape
|
||||
/// hatch (see PhysicsWorldTests) to assert on the native side's own
|
||||
/// bookkeeping, not just PhysicsWorld's C#-side dictionary.</summary>
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_GetBodyCount();
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_CreateBoxBody(
|
||||
int worldHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw,
|
||||
float halfWidth, float halfHeight, float halfDepth,
|
||||
int bodyType, float density, float friction, float restitution);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern int Lingua_CreateSphereBody(
|
||||
int worldHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw,
|
||||
float radius,
|
||||
int bodyType, float density, float friction, float restitution);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_DestroyBody(int bodyHandle);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_GetBodyTransform(
|
||||
int bodyHandle,
|
||||
out float px, out float py, out float pz,
|
||||
out float qx, out float qy, out float qz, out float qw);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_SetBodyTransform(
|
||||
int bodyHandle,
|
||||
float px, float py, float pz,
|
||||
float qx, float qy, float qz, float qw);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_ApplyLinearImpulse(
|
||||
int bodyHandle, float ix, float iy, float iz, [MarshalAs(UnmanagedType.U1)] bool wake);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_GetLinearVelocity(int bodyHandle, out float vx, out float vy, out float vz);
|
||||
|
||||
[DllImport(Lib)]
|
||||
public static extern void Lingua_SetLinearVelocity(int bodyHandle, float vx, float vy, float vz);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics.Contracts;
|
||||
|
||||
namespace Engine.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// M4's physics plugin: Box3D, over the shim in native/physics-native/ —
|
||||
/// see that file's own doc comment for why nothing crosses the P/Invoke
|
||||
/// boundary but plain scalars. One system, on Stage.FixedUpdate: sync new/
|
||||
/// removed Rigidbodies, step once, write results back to Transform. Each
|
||||
/// FixedUpdate invocation is exactly one physics step at ITime.
|
||||
/// FixedDeltaTime — Engine.Host's accumulator (Time.ConsumeFixedSteps)
|
||||
/// decides how many times that runs this frame, not this plugin.
|
||||
/// </summary>
|
||||
public sealed class PhysicsPlugin : IPlugin
|
||||
{
|
||||
private static readonly Vector3 Gravity = new(0f, -9.81f, 0f);
|
||||
|
||||
private PhysicsWorld? _world;
|
||||
private ITime? _time;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_time = ctx.Time;
|
||||
_world = new PhysicsWorld(Gravity, ctx.Log);
|
||||
ctx.Services.Provide<IPhysicsService>(new PhysicsService(_world));
|
||||
|
||||
ctx.Schedule.Add(Stage.FixedUpdate, Step)
|
||||
.Reads<Rigidbody>()
|
||||
.Reads<BoxCollider>()
|
||||
.Reads<SphereCollider>();
|
||||
|
||||
ctx.Log.Info("physics world ready (Box3D)");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("engine.physics");
|
||||
ctx.Services.Revoke<IPhysicsService>();
|
||||
_world?.Dispose();
|
||||
_world = null;
|
||||
_time = null;
|
||||
}
|
||||
|
||||
private void Step(IWorld world)
|
||||
{
|
||||
_world!.Sync(world);
|
||||
_world.Step(_time!.FixedDeltaTime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics.Contracts;
|
||||
|
||||
namespace Engine.Physics;
|
||||
|
||||
internal sealed class PhysicsService(PhysicsWorld world) : IPhysicsService
|
||||
{
|
||||
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) =>
|
||||
world.ApplyLinearImpulse(go, impulse, wake);
|
||||
|
||||
public Vector3 GetLinearVelocity(GameObject go) => world.GetLinearVelocity(go);
|
||||
|
||||
public void SetLinearVelocity(GameObject go, Vector3 velocity) => world.SetLinearVelocity(go, velocity);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics.Contracts;
|
||||
|
||||
namespace Engine.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the one native Box3D world this plugin creates, and the GameObject
|
||||
/// <-> native body handle mapping. <see cref="Sync"/> creates bodies for
|
||||
/// any GameObject that's grown a Rigidbody since last frame and destroys
|
||||
/// bodies for any that lost one (component removed, or the GameObject
|
||||
/// itself was destroyed) — there's no destruction event to hook, so this
|
||||
/// diffs Query<Rigidbody>() against the tracked set every FixedUpdate
|
||||
/// instead. <see cref="Step"/> advances the simulation once and writes
|
||||
/// every tracked body's new transform back into its GameObject.
|
||||
/// </summary>
|
||||
internal sealed class PhysicsWorld : IDisposable
|
||||
{
|
||||
private readonly int _handle;
|
||||
private readonly ILogger _log;
|
||||
private readonly Dictionary<GameObject, int> _bodies = [];
|
||||
private readonly HashSet<GameObject> _warnedFailed = [];
|
||||
|
||||
public PhysicsWorld(Vector3 gravity, ILogger log)
|
||||
{
|
||||
_log = log;
|
||||
_handle = Native.Lingua_CreateWorld(gravity.X, gravity.Y, gravity.Z);
|
||||
}
|
||||
|
||||
public void Sync(IWorld world)
|
||||
{
|
||||
var live = new HashSet<GameObject>();
|
||||
|
||||
foreach (var go in world.Query<Rigidbody>())
|
||||
{
|
||||
live.Add(go);
|
||||
if (!_bodies.ContainsKey(go))
|
||||
TryCreateBody(go);
|
||||
}
|
||||
|
||||
// Comparing counts alone would miss this: destroying one tracked
|
||||
// GameObject and gaining a different untracked one in the same
|
||||
// Sync leaves _bodies.Count == live.Count with the sets actually
|
||||
// different — the stale native body would never get destroyed and
|
||||
// would keep simulating forever. Restore (every ExitPlay) hits
|
||||
// this reliably whenever the scene has both a Rigidbody+collider
|
||||
// GameObject and a Rigidbody-without-collider one, since the
|
||||
// latter never enters _bodies to begin with.
|
||||
var stale = _bodies.Keys.Where(go => !live.Contains(go)).ToList();
|
||||
if (stale.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var go in stale)
|
||||
{
|
||||
Native.Lingua_DestroyBody(_bodies[go]);
|
||||
_bodies.Remove(go);
|
||||
_warnedFailed.Remove(go);
|
||||
}
|
||||
}
|
||||
|
||||
public void Step(float timeStep, int subStepCount = 4)
|
||||
{
|
||||
Native.Lingua_WorldStep(_handle, timeStep, subStepCount);
|
||||
|
||||
foreach (var (go, handle) in _bodies)
|
||||
{
|
||||
Native.Lingua_GetBodyTransform(handle, out var px, out var py, out var pz, out var qx, out var qy, out var qz, out var qw);
|
||||
|
||||
var t = go.Transform;
|
||||
t.LocalPosition = new Vector3(px, py, pz);
|
||||
t.LocalRotation = new Quaternion(qx, qy, qz, qw);
|
||||
go.Transform = t;
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake)
|
||||
{
|
||||
if (_bodies.TryGetValue(go, out var handle))
|
||||
Native.Lingua_ApplyLinearImpulse(handle, impulse.X, impulse.Y, impulse.Z, wake);
|
||||
}
|
||||
|
||||
public Vector3 GetLinearVelocity(GameObject go)
|
||||
{
|
||||
if (!_bodies.TryGetValue(go, out var handle))
|
||||
return Vector3.Zero;
|
||||
|
||||
Native.Lingua_GetLinearVelocity(handle, out var vx, out var vy, out var vz);
|
||||
return new Vector3(vx, vy, vz);
|
||||
}
|
||||
|
||||
public void SetLinearVelocity(GameObject go, Vector3 velocity)
|
||||
{
|
||||
if (_bodies.TryGetValue(go, out var handle))
|
||||
Native.Lingua_SetLinearVelocity(handle, velocity.X, velocity.Y, velocity.Z);
|
||||
}
|
||||
|
||||
private void TryCreateBody(GameObject go)
|
||||
{
|
||||
var rb = go.GetComponent<Rigidbody>()!;
|
||||
var pos = go.Transform.LocalPosition;
|
||||
var rot = go.Transform.LocalRotation;
|
||||
|
||||
var box = go.GetComponent<BoxCollider>();
|
||||
var sphere = go.GetComponent<SphereCollider>();
|
||||
|
||||
int handle;
|
||||
if (box is not null)
|
||||
{
|
||||
handle = Native.Lingua_CreateBoxBody(
|
||||
_handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W,
|
||||
box.HalfExtents.X, box.HalfExtents.Y, box.HalfExtents.Z,
|
||||
(int)rb.Type, rb.Density, rb.Friction, rb.Restitution);
|
||||
}
|
||||
else if (sphere is not null)
|
||||
{
|
||||
handle = Native.Lingua_CreateSphereBody(
|
||||
_handle, pos.X, pos.Y, pos.Z, rot.X, rot.Y, rot.Z, rot.W,
|
||||
sphere.Radius, (int)rb.Type, rb.Density, rb.Friction, rb.Restitution);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_warnedFailed.Add(go))
|
||||
_log.Warn($"'{go.Name}' has a Rigidbody but no BoxCollider/SphereCollider — no physics body created.");
|
||||
return;
|
||||
}
|
||||
|
||||
// -1 means the native shim refused (an invalid world handle, or
|
||||
// its fixed-capacity body table — 8192 — is full). Storing it
|
||||
// anyway would silently "work": every later Lingua_GetBodyTransform
|
||||
// call on handle -1 fails ValidBody's check and leaves the out
|
||||
// params at their P/Invoke-zeroed default, teleporting the
|
||||
// GameObject to the origin with a degenerate all-zero rotation
|
||||
// every FixedUpdate — no exception, no log, just a wrong position.
|
||||
if (handle < 0)
|
||||
{
|
||||
if (_warnedFailed.Add(go))
|
||||
_log.Warn($"'{go.Name}' failed to create a native physics body (world invalid or body table full).");
|
||||
return;
|
||||
}
|
||||
|
||||
_bodies[go] = handle;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var handle in _bodies.Values)
|
||||
Native.Lingua_DestroyBody(handle);
|
||||
|
||||
_bodies.Clear();
|
||||
Native.Lingua_DestroyWorld(_handle);
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "engine.physics",
|
||||
"version": "0.1.0",
|
||||
"contracts": "Engine.Physics.Contracts.dll",
|
||||
"assembly": "Engine.Physics.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Render.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// A unit cube (1x1x1 before Transform.LocalScale), same no-size-fields
|
||||
/// reasoning as QuadRenderer — Transform.LocalScale already means "how
|
||||
/// big." Added for M4's physics demo: a BoxCollider falling and settling
|
||||
/// only reads as a real physics object on screen if it actually looks like
|
||||
/// a box, not a flat card.
|
||||
/// </summary>
|
||||
public sealed class CubeRenderer : Component;
|
||||
@@ -12,11 +12,14 @@ namespace Engine.Render;
|
||||
|
||||
/// <summary>
|
||||
/// M3's render pipeline: a real perspective camera, drawing every
|
||||
/// GameObject with a QuadRenderer at its own WorldMatrix — upgraded from
|
||||
/// M2's single hardcoded NDC-space quad specifically because gizmos need
|
||||
/// real 3D geometry and a real camera to mean anything (a handle dragged
|
||||
/// in screen space has to map onto an actual 3D axis). See M3 in
|
||||
/// docs/kernel-contract.md §8.
|
||||
/// GameObject with a QuadRenderer or CubeRenderer at its own WorldMatrix —
|
||||
/// upgraded from M2's single hardcoded NDC-space quad specifically because
|
||||
/// gizmos need real 3D geometry and a real camera to mean anything (a
|
||||
/// handle dragged in screen space has to map onto an actual 3D axis). See
|
||||
/// M3 in docs/kernel-contract.md §8. CubeRenderer itself is M4's addition,
|
||||
/// for the same reason M3's gizmo needed a real camera: a BoxCollider
|
||||
/// falling and settling only reads as a physics object if it looks like
|
||||
/// one, not a flat card.
|
||||
///
|
||||
/// GameObject.WorldMatrix and this plugin's own matrices are both
|
||||
/// System.Numerics.Matrix4x4, which is row-vector (v' = v * M, and
|
||||
@@ -95,11 +98,64 @@ public sealed class RenderPlugin : IPlugin
|
||||
0.5f, 0.5f, 0f, 1f, 1f,
|
||||
];
|
||||
|
||||
// Unit cube (1x1x1 before LocalScale), centered at its own origin — see
|
||||
// CubeRenderer's own doc comment for why this exists (M4's physics
|
||||
// demo needs falling BoxColliders to actually look like boxes). No
|
||||
// face culling is enabled anywhere in this plugin, so winding order
|
||||
// doesn't matter here the way it would with CullFace on.
|
||||
private static readonly float[] CubeVertices =
|
||||
[
|
||||
// position uv
|
||||
-0.5f, -0.5f, 0.5f, 0f, 0f, // front (+Z)
|
||||
0.5f, -0.5f, 0.5f, 1f, 0f,
|
||||
0.5f, 0.5f, 0.5f, 1f, 1f,
|
||||
0.5f, 0.5f, 0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, 0.5f, 0f, 1f,
|
||||
-0.5f, -0.5f, 0.5f, 0f, 0f,
|
||||
|
||||
0.5f, -0.5f, -0.5f, 0f, 0f, // back (-Z)
|
||||
-0.5f, -0.5f, -0.5f, 1f, 0f,
|
||||
-0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
0.5f, 0.5f, -0.5f, 0f, 1f,
|
||||
0.5f, -0.5f, -0.5f, 0f, 0f,
|
||||
|
||||
-0.5f, -0.5f, -0.5f, 0f, 0f, // left (-X)
|
||||
-0.5f, -0.5f, 0.5f, 1f, 0f,
|
||||
-0.5f, 0.5f, 0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, 0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, -0.5f, 0f, 1f,
|
||||
-0.5f, -0.5f, -0.5f, 0f, 0f,
|
||||
|
||||
0.5f, -0.5f, 0.5f, 0f, 0f, // right (+X)
|
||||
0.5f, -0.5f, -0.5f, 1f, 0f,
|
||||
0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
0.5f, 0.5f, 0.5f, 0f, 1f,
|
||||
0.5f, -0.5f, 0.5f, 0f, 0f,
|
||||
|
||||
-0.5f, 0.5f, 0.5f, 0f, 0f, // top (+Y)
|
||||
0.5f, 0.5f, 0.5f, 1f, 0f,
|
||||
0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
0.5f, 0.5f, -0.5f, 1f, 1f,
|
||||
-0.5f, 0.5f, -0.5f, 0f, 1f,
|
||||
-0.5f, 0.5f, 0.5f, 0f, 0f,
|
||||
|
||||
-0.5f, -0.5f, -0.5f, 0f, 0f, // bottom (-Y)
|
||||
0.5f, -0.5f, -0.5f, 1f, 0f,
|
||||
0.5f, -0.5f, 0.5f, 1f, 1f,
|
||||
0.5f, -0.5f, 0.5f, 1f, 1f,
|
||||
-0.5f, -0.5f, 0.5f, 0f, 1f,
|
||||
-0.5f, -0.5f, -0.5f, 0f, 0f,
|
||||
];
|
||||
|
||||
private GL? _gl;
|
||||
private IEngineWindow? _window;
|
||||
private CameraService? _camera;
|
||||
private uint _vao;
|
||||
private uint _vbo;
|
||||
private uint _cubeVao;
|
||||
private uint _cubeVbo;
|
||||
private uint _program;
|
||||
private uint _texture;
|
||||
private int _modelLocation;
|
||||
@@ -143,6 +199,20 @@ public sealed class RenderPlugin : IPlugin
|
||||
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
_cubeVao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(_cubeVao);
|
||||
|
||||
_cubeVbo = _gl.GenBuffer();
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _cubeVbo);
|
||||
_gl.BufferData<float>(BufferTargetARB.ArrayBuffer, CubeVertices, BufferUsageARB.StaticDraw);
|
||||
|
||||
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
|
||||
@@ -159,7 +229,7 @@ public sealed class RenderPlugin : IPlugin
|
||||
ctx.Events.Subscribe(_onTextureReloaded);
|
||||
|
||||
ctx.Services.Provide<IScreenCapture>(new GlScreenCapture(_gl, _window));
|
||||
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>();
|
||||
ctx.Schedule.Add(Stage.Render, Draw).Reads<QuadRenderer>().Reads<CubeRenderer>();
|
||||
ctx.Schedule.Add(Stage.Present, Present);
|
||||
ctx.Log.Info("GL context created, 3D quad pipeline ready");
|
||||
}
|
||||
@@ -176,6 +246,8 @@ public sealed class RenderPlugin : IPlugin
|
||||
_gl.DeleteTexture(_texture);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteVertexArray(_cubeVao);
|
||||
_gl.DeleteBuffer(_cubeVbo);
|
||||
_gl.DeleteProgram(_program);
|
||||
_gl.Dispose();
|
||||
}
|
||||
@@ -237,6 +309,13 @@ public sealed class RenderPlugin : IPlugin
|
||||
SetMatrix(_modelLocation, go.WorldMatrix);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
|
||||
}
|
||||
|
||||
_gl.BindVertexArray(_cubeVao);
|
||||
foreach (var go in world.Query<CubeRenderer>())
|
||||
{
|
||||
SetMatrix(_modelLocation, go.WorldMatrix);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, 36);
|
||||
}
|
||||
}
|
||||
|
||||
// Split from Draw() into its own Stage.Present system so engine.editor
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
using Sandbox.Echo.Contracts;
|
||||
|
||||
namespace Sandbox.FailingConfigure;
|
||||
|
||||
/// <summary>
|
||||
/// A test fixture, not a real plugin — exists only so
|
||||
/// FailedConfigureRollbackTests can exercise PluginHost.Load's rollback
|
||||
/// path against a real ALC/assembly load, the same way sandbox.echo exists
|
||||
/// for the successful-load path. Registers a system that increments a
|
||||
/// shared Ping component (so there's a real, deterministic side effect for
|
||||
/// the test to check — did the dangling system actually get removed, not
|
||||
/// just "did the ALC eventually get GC'd," which turned out to happen
|
||||
/// either way regardless of whether rollback ran, making it useless as a
|
||||
/// regression signal here), then throws — reproducing "Configure got
|
||||
/// partway through before failing," not "Configure failed immediately."
|
||||
/// </summary>
|
||||
public sealed class FailingConfigurePlugin : IPlugin
|
||||
{
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.Add(Stage.Update, Tick).Writes<Ping>();
|
||||
ctx.Events.Subscribe<PluginLoaded>(_ => { });
|
||||
|
||||
throw new InvalidOperationException("deliberate failure for PluginHostTests");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("sandbox.failing-configure");
|
||||
ctx.Events.RemoveAllFrom("sandbox.failing-configure");
|
||||
}
|
||||
|
||||
private static void Tick(IWorld world)
|
||||
{
|
||||
foreach (var go in world.Query<Ping>())
|
||||
go.GetComponent<Ping>()!.Count++;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<!-- Reusing sandbox.echo's own Ping component, not defining a new one:
|
||||
it's already a shared, cross-ALC-safe Default-ALC type, and gives
|
||||
FailedConfigureRollbackTests a real, deterministic side effect to
|
||||
assert on (Ping.Count) instead of a GC-timing-sensitive check. -->
|
||||
<ProjectReference Include="..\..\sandbox.echo\Sandbox.Echo.Contracts\Sandbox.Echo.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "sandbox.failing-configure",
|
||||
"version": "0.1.0",
|
||||
"assembly": "Sandbox.FailingConfigure.dll",
|
||||
"dependsOn": {},
|
||||
"reloadable": true
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>PhysicsDemoGame</RootNamespace>
|
||||
<AssemblyName>PhysicsDemoGame</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../../src/Engine.Kernel/Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.input/Engine.Input.Contracts/Engine.Input.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.physics/Engine.Physics.Contracts/Engine.Physics.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.audio/Engine.Audio.Contracts/Engine.Audio.Contracts.csproj" />
|
||||
<ProjectReference Include="../../../../plugins/engine.render/Engine.Render.Contracts/Engine.Render.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,119 @@
|
||||
using System.Numerics;
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Input.Contracts;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics.Contracts;
|
||||
using Engine.Render.Contracts;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace PhysicsDemoGame;
|
||||
|
||||
/// <summary>
|
||||
/// M4's "one small game, end to end": pressing Space drops a new box from
|
||||
/// above the ground; engine.physics simulates it falling and settling;
|
||||
/// engine.render draws it as a real cube (CubeRenderer, added specifically
|
||||
/// for this); this plugin plays a bounce sound (engine.audio) the moment
|
||||
/// each box's fall actually stops. Physics, audio, input, and rendering
|
||||
/// all cooperate here through nothing but the kernel's own vocabulary —
|
||||
/// this plugin never references engine.physics/audio/render's
|
||||
/// implementation assemblies, only their Contracts.
|
||||
///
|
||||
/// "Landed" isn't a Box3D contact event — the native shim doesn't expose
|
||||
/// one (see native/physics-native/lingua_physics.c's own doc comment on
|
||||
/// why nothing but scalars crosses that boundary; a contact callback would
|
||||
/// need to carry structured per-contact data across it, exactly what's
|
||||
/// kept out). Honest scope for a demo this size: watch each spawned box's
|
||||
/// own Y velocity via IPhysicsService every frame, and call it landed the
|
||||
/// first time a real fall (velocity past FallingThreshold at some point)
|
||||
/// settles back near zero.
|
||||
/// </summary>
|
||||
public sealed class PhysicsDemoGamePlugin : IPlugin
|
||||
{
|
||||
private const float SpawnHeight = 6f;
|
||||
private const float FallingThreshold = -1f;
|
||||
private const float RestThreshold = 0.3f;
|
||||
|
||||
private readonly Random _random = new();
|
||||
private readonly List<GameObject> _spawned = [];
|
||||
private readonly HashSet<GameObject> _hasFallen = [];
|
||||
private readonly HashSet<GameObject> _landed = [];
|
||||
|
||||
private IEngineInput? _input;
|
||||
private IPhysicsService? _physics;
|
||||
private IAudioService? _audio;
|
||||
private GameObject? _bounceSound;
|
||||
private bool _spacePressedLastFrame;
|
||||
|
||||
public void Configure(IPluginContext ctx)
|
||||
{
|
||||
_input = ctx.Services.Require<IEngineInput>();
|
||||
_physics = ctx.Services.Require<IPhysicsService>();
|
||||
_audio = ctx.Services.Require<IAudioService>();
|
||||
|
||||
ctx.Schedule.Add(Stage.Update, Tick)
|
||||
.Writes<Rigidbody>()
|
||||
.Writes<BoxCollider>()
|
||||
.Writes<CubeRenderer>();
|
||||
|
||||
ctx.Log.Info("physics demo ready — press Space to drop a box");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("physics-demo-game");
|
||||
_spawned.Clear();
|
||||
_hasFallen.Clear();
|
||||
_landed.Clear();
|
||||
_bounceSound = null;
|
||||
_input = null;
|
||||
_physics = null;
|
||||
_audio = null;
|
||||
}
|
||||
|
||||
private void Tick(IWorld world)
|
||||
{
|
||||
_bounceSound ??= world.Roots.FirstOrDefault(go => go.Name == "Bounce");
|
||||
|
||||
var spacePressed = _input!.IsKeyDown(Key.Space);
|
||||
if (spacePressed && !_spacePressedLastFrame)
|
||||
Spawn(world);
|
||||
_spacePressedLastFrame = spacePressed;
|
||||
|
||||
foreach (var box in _spawned)
|
||||
{
|
||||
if (_landed.Contains(box))
|
||||
continue;
|
||||
|
||||
var velocityY = _physics!.GetLinearVelocity(box).Y;
|
||||
|
||||
if (velocityY < FallingThreshold)
|
||||
{
|
||||
_hasFallen.Add(box);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (_hasFallen.Contains(box) && MathF.Abs(velocityY) < RestThreshold)
|
||||
{
|
||||
_landed.Add(box);
|
||||
if (_bounceSound is not null)
|
||||
_audio!.Play(_bounceSound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Spawn(IWorld world)
|
||||
{
|
||||
var go = world.CreateGameObject($"Box{_spawned.Count}");
|
||||
go.Transform = Transform.Identity;
|
||||
go.Transform.LocalPosition = new Vector3(
|
||||
(float)(_random.NextDouble() * 4 - 2), SpawnHeight, (float)(_random.NextDouble() * 4 - 2));
|
||||
|
||||
go.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
go.AddComponent<BoxCollider>();
|
||||
go.AddComponent<CubeRenderer>();
|
||||
|
||||
_spawned.Add(go);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "physics-demo-game",
|
||||
"version": "0.1.0",
|
||||
"assembly": "PhysicsDemoGame.dll",
|
||||
"dependsOn": {
|
||||
"engine.input": "^0.1",
|
||||
"engine.physics": "^0.1",
|
||||
"engine.audio": "^0.1",
|
||||
"engine.render": "^0.1"
|
||||
},
|
||||
"reloadable": true
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 84 B |
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"engineVersion": "^0.1",
|
||||
"plugins": [
|
||||
{ "id": "engine.physics" }
|
||||
],
|
||||
"pluginPaths": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"engineVersion": "^0.1",
|
||||
"plugins": [
|
||||
{ "id": "engine.windowing" },
|
||||
{ "id": "engine.assets" },
|
||||
{ "id": "engine.render" },
|
||||
{ "id": "engine.input" },
|
||||
{ "id": "engine.physics" },
|
||||
{ "id": "engine.audio" },
|
||||
{ "id": "physics-demo-game" }
|
||||
],
|
||||
"pluginPaths": ["GamePlugins"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "Ground",
|
||||
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
|
||||
"components": [
|
||||
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } },
|
||||
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"name": "DemoBox",
|
||||
"transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
|
||||
"components": [
|
||||
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } },
|
||||
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} }
|
||||
],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
[
|
||||
{
|
||||
"name": "Ground",
|
||||
"transform": {
|
||||
"position": [0, 0, 0],
|
||||
"rotation": [0, 0, 0, 1],
|
||||
"scale": [8, 1, 8]
|
||||
},
|
||||
"components": [
|
||||
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 0, "Friction": 0.6, "Restitution": 0.3 } },
|
||||
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": { "HalfExtents": { "X": 4, "Y": 0.5, "Z": 4 } } },
|
||||
{ "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"name": "DemoBox",
|
||||
"transform": { "position": [0, 3, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
|
||||
"components": [
|
||||
{ "type": "Engine.Physics.Contracts.Rigidbody, Engine.Physics.Contracts", "data": { "Type": 2, "Friction": 0.6, "Restitution": 0.3 } },
|
||||
{ "type": "Engine.Physics.Contracts.BoxCollider, Engine.Physics.Contracts", "data": {} },
|
||||
{ "type": "Engine.Render.Contracts.CubeRenderer, Engine.Render.Contracts", "data": {} }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"name": "Music",
|
||||
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
|
||||
"components": [
|
||||
{ "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/music.wav", "Volume": 0.35, "Loop": true, "PlayOnAwake": true } }
|
||||
],
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"name": "Bounce",
|
||||
"transform": { "position": [0, 0, 0], "rotation": [0, 0, 0, 1], "scale": [1, 1, 1] },
|
||||
"components": [
|
||||
{ "type": "Engine.Audio.Contracts.AudioSource, Engine.Audio.Contracts", "data": { "ClipPath": "assets/bounce.wav", "Volume": 0.8, "Loop": false, "PlayOnAwake": false } }
|
||||
],
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stages the shared engine plugins, builds+places the sample's own
|
||||
# game-specific plugin (physics-demo-game, listed in this project's own
|
||||
# pluginPaths rather than the shared engine catalog — it's this game's
|
||||
# logic, not a reusable engine plugin), then runs samples/PhysicsDemo.
|
||||
# Extra args are forwarded to `engine run`.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG="${CONFIGURATION:-Debug}"
|
||||
STAGE_DIR="$REPO_ROOT/.stage/plugins"
|
||||
GAME_DIR="$REPO_ROOT/samples/PhysicsDemo/GamePlugins/physics-demo-game"
|
||||
|
||||
"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR"
|
||||
|
||||
cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.dll "$GAME_DIR/" 2>/dev/null || true
|
||||
cp "$GAME_DIR/bin/$CONFIG/net9.0/"*.pdb "$GAME_DIR/" 2>/dev/null || true
|
||||
|
||||
cd "$REPO_ROOT/samples/PhysicsDemo"
|
||||
exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \
|
||||
--plugins "$STAGE_DIR" --project project.json --scene scene.json "$@"
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Stages every plugin, then runs samples/WindowDemo windowed. Extra args
|
||||
# are forwarded to `engine run` — e.g. ./run-sample.sh --screenshot out.png
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
CONFIG="${CONFIGURATION:-Debug}"
|
||||
STAGE_DIR="$REPO_ROOT/.stage/plugins"
|
||||
|
||||
"$REPO_ROOT/scripts/stage-plugins.sh" "$STAGE_DIR"
|
||||
|
||||
cd "$REPO_ROOT/samples/WindowDemo"
|
||||
exec dotnet "$REPO_ROOT/src/Engine.Host/bin/$CONFIG/net9.0/Engine.Host.dll" run --windowed \
|
||||
--plugins "$STAGE_DIR" --project project.json --scene scene.json "$@"
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds every plugin and lays each one out into a single flat directory
|
||||
# per id — plugin.json alongside its built DLLs — which is the layout
|
||||
# PluginHost.Load actually expects. Needed because Contracts and
|
||||
# Implementation build into two separate bin/ folders; without this step
|
||||
# there's nowhere valid to point --plugins at.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
STAGE_DIR="${1:-$REPO_ROOT/.stage/plugins}"
|
||||
CONFIG="${CONFIGURATION:-Debug}"
|
||||
|
||||
echo "Building solution ($CONFIG)..."
|
||||
dotnet build "$REPO_ROOT/LinguaEngine.sln" -c "$CONFIG" >/dev/null
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$STAGE_DIR"
|
||||
|
||||
for plugin_dir in "$REPO_ROOT"/plugins/*/; do
|
||||
id="$(basename "$plugin_dir")"
|
||||
[ -f "$plugin_dir/plugin.json" ] || continue
|
||||
|
||||
# engine.foo -> Engine.Foo (the Implementation project's folder name)
|
||||
impl_name="$(awk -F. '{for(i=1;i<=NF;i++){$i=toupper(substr($i,1,1)) substr($i,2)}}1' OFS=. <<<"$id")"
|
||||
impl_out="$plugin_dir/$impl_name/bin/$CONFIG/net9.0"
|
||||
|
||||
if [ ! -d "$impl_out" ]; then
|
||||
echo "warning: no build output for '$id' at $impl_out, skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$STAGE_DIR/$id"
|
||||
cp -r "$impl_out/." "$STAGE_DIR/$id/"
|
||||
cp "$plugin_dir/plugin.json" "$STAGE_DIR/$id/"
|
||||
done
|
||||
|
||||
echo "Staged plugins into: $STAGE_DIR"
|
||||
@@ -230,13 +230,24 @@ if (windowed)
|
||||
Console.WriteLine($"Reloading '{cmd.Argument}'...");
|
||||
try
|
||||
{
|
||||
host.Unload(cmd.Argument);
|
||||
// Not unconditional: a plugin whose previous
|
||||
// reload already failed inside Load (a bad
|
||||
// Configure — now rolled back, see PluginHost.
|
||||
// Load) isn't loaded any more, and Unload would
|
||||
// just throw "not loaded" on this retry instead of
|
||||
// ever reaching Load again.
|
||||
if (host.IsLoaded(cmd.Argument))
|
||||
host.Unload(cmd.Argument);
|
||||
|
||||
host.Load(pluginDirectory);
|
||||
Console.WriteLine($"Reloaded '{cmd.Argument}'. World state and the window were untouched.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Failed to reload '{cmd.Argument}': {ex.Message}");
|
||||
Console.Error.WriteLine(
|
||||
$"Failed to reload '{cmd.Argument}': {ex.Message} " +
|
||||
$"'{cmd.Argument}' is now unloaded (not just still running the old code) — " +
|
||||
$"fix the error and run 'r {cmd.Argument}' again.");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -270,9 +281,19 @@ if (windowed)
|
||||
// before this plugin existed. With one loaded, Update only runs
|
||||
// while actually Playing: Edit mode still renders the scene every
|
||||
// frame (so the editor UI stays responsive and the view isn't
|
||||
// frozen mid-edit), it just never ticks it.
|
||||
// frozen mid-edit), it just never ticks it. FixedUpdate is gated
|
||||
// the same way and for the same reason — physics shouldn't step
|
||||
// while Edit mode has the world frozen — and only accumulates time
|
||||
// while playing, so Play doesn't open with a catch-up burst of
|
||||
// steps for however long Edit mode had been sitting idle.
|
||||
if (playMode is null || playMode.IsPlaying)
|
||||
{
|
||||
var fixedSteps = time.ConsumeFixedSteps();
|
||||
for (var step = 0; step < fixedSteps; step++)
|
||||
schedule.RunStage(Stage.FixedUpdate, world);
|
||||
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
}
|
||||
|
||||
schedule.RunStage(Stage.Render, world);
|
||||
schedule.RunStage(Stage.Present, world);
|
||||
@@ -313,6 +334,11 @@ else
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
time.Tick(headlessDeltaTime);
|
||||
|
||||
var fixedSteps = time.ConsumeFixedSteps();
|
||||
for (var step = 0; step < fixedSteps; step++)
|
||||
schedule.RunStage(Stage.FixedUpdate, world);
|
||||
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,12 @@ namespace Engine.Kernel.Diagnostics;
|
||||
/// <summary>
|
||||
/// Frame clock. See docs/kernel-contract.md §2.
|
||||
///
|
||||
/// No fixed-step accumulator yet — that was in the original kernel scope
|
||||
/// but nothing consumes it: building it now, with no physics system to
|
||||
/// test it against, would be exactly the kind of untested speculative
|
||||
/// machinery this project has avoided everywhere else. It arrives with
|
||||
/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate"
|
||||
/// stage without a real fixed-timestep accumulator behind it would be
|
||||
/// actively misleading, not just incomplete.
|
||||
/// M4 adds the fixed-step accumulator this doc comment used to say was
|
||||
/// missing: engine.physics needs to step Box3D at a constant rate
|
||||
/// regardless of how the real frame rate wobbles, and a "FixedUpdate"
|
||||
/// stage without a real accumulator behind it would be actively
|
||||
/// misleading, not just incomplete. See Stage.FixedUpdate and
|
||||
/// Time.ConsumeFixedSteps for the actual mechanism.
|
||||
/// </summary>
|
||||
public interface ITime
|
||||
{
|
||||
@@ -20,4 +19,12 @@ public interface ITime
|
||||
double ElapsedTime { get; }
|
||||
|
||||
int FrameCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The constant step size Stage.FixedUpdate always runs at, regardless
|
||||
/// of the real frame rate — 1/50s. Not per-project configurable yet:
|
||||
/// nothing has needed a different rate, and a setting nobody's asked
|
||||
/// for is speculative the same way an unconsumed accumulator was.
|
||||
/// </summary>
|
||||
float FixedDeltaTime { get; }
|
||||
}
|
||||
|
||||
@@ -2,15 +2,26 @@ namespace Engine.Kernel.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only to plugins (ITime); advanced by whoever owns the frame loop —
|
||||
/// Engine.Host today, headless or windowed — via <see cref="Tick"/>, which
|
||||
/// isn't on the interface. Same split as Schedule/ISchedule: the host gets
|
||||
/// the extra, plugin-facing code doesn't.
|
||||
/// Engine.Host today, headless or windowed — via <see cref="Tick"/> and
|
||||
/// <see cref="ConsumeFixedSteps"/>, neither on the interface. Same split as
|
||||
/// Schedule/ISchedule: the host gets the extra, plugin-facing code doesn't.
|
||||
/// </summary>
|
||||
public sealed class Time : ITime
|
||||
{
|
||||
// Capped so a real stall — a debugger pause, a slow disk load — can't
|
||||
// turn into a catch-up burst of hundreds of physics steps executing
|
||||
// back to back, each one still assuming a normal frame budget around
|
||||
// it. Five steps behind (0.1s at the default rate) already means
|
||||
// something else is wrong; better to visibly fall behind real time
|
||||
// than to freeze the process trying to simulate through it.
|
||||
private const int MaxStepsPerFrame = 5;
|
||||
|
||||
private float _accumulator;
|
||||
|
||||
public float DeltaTime { get; private set; }
|
||||
public double ElapsedTime { get; private set; }
|
||||
public int FrameCount { get; private set; }
|
||||
public float FixedDeltaTime { get; } = 1f / 50f;
|
||||
|
||||
public void Tick(float deltaTime)
|
||||
{
|
||||
@@ -18,4 +29,25 @@ public sealed class Time : ITime
|
||||
ElapsedTime += deltaTime;
|
||||
FrameCount++;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds this frame's DeltaTime to the accumulator and returns how many
|
||||
/// FixedDeltaTime-sized steps it can pay for, having already deducted
|
||||
/// that much. Call once per frame, then run Stage.FixedUpdate that many
|
||||
/// times (zero is normal — most frames are shorter than FixedDeltaTime
|
||||
/// at 50Hz against a 60+fps display).
|
||||
/// </summary>
|
||||
public int ConsumeFixedSteps()
|
||||
{
|
||||
_accumulator += DeltaTime;
|
||||
|
||||
var steps = 0;
|
||||
while (_accumulator >= FixedDeltaTime && steps < MaxStepsPerFrame)
|
||||
{
|
||||
_accumulator -= FixedDeltaTime;
|
||||
steps++;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,34 @@ public sealed class PluginHost(
|
||||
events.RegisterPlugin(manifest.Id, implAssembly);
|
||||
var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time);
|
||||
|
||||
instance.Configure(ctx);
|
||||
try
|
||||
{
|
||||
instance.Configure(ctx);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Configure can fail after already doing real work — Schedule.
|
||||
// Add, Events.Subscribe, Services.Provide calls all happen
|
||||
// before whatever line actually throws. None of that unwinds
|
||||
// on its own, and because _loaded never gets an entry for this
|
||||
// id below, the plugin ends up neither loaded nor unloadable:
|
||||
// its ALC stays rooted forever and any systems/subscriptions it
|
||||
// did register before throwing keep running. Best-effort
|
||||
// Shutdown first — it's the only thing that knows which
|
||||
// services this specific plugin provided, so it's the only way
|
||||
// to Revoke them — then the two RemoveAllFrom calls PluginHost
|
||||
// itself can make unconditionally, then unload the ALC. Each
|
||||
// step is wrapped so a broken Shutdown/unload can't hide the
|
||||
// real Configure failure being rethrown below.
|
||||
try { instance.Shutdown(ctx); } catch { /* best-effort */ }
|
||||
|
||||
schedule.RemoveAllFrom(manifest.Id);
|
||||
events.RemoveAllFrom(manifest.Id);
|
||||
|
||||
try { alc.Unload(); } catch { /* best-effort */ }
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
|
||||
events.Publish(new PluginLoaded(manifest.Id));
|
||||
@@ -112,6 +139,16 @@ public sealed class PluginHost(
|
||||
return loadedIds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets a caller check before calling Unload/Load instead of catching
|
||||
/// InvalidOperationException to find out — Engine.Host's live-reload
|
||||
/// command needs this: a plugin whose previous reload attempt failed
|
||||
/// partway through Load isn't loaded any more (see Load's own rollback
|
||||
/// on a Configure failure), so unconditionally trying Unload first
|
||||
/// would itself throw "not loaded" on every retry.
|
||||
/// </summary>
|
||||
public bool IsLoaded(string pluginId) => _loaded.ContainsKey(pluginId);
|
||||
|
||||
/// <summary>
|
||||
/// Runs Shutdown(), then unloads the plugin's ALC. Returns a weak
|
||||
/// reference to the ALC so a caller can verify it actually collected —
|
||||
|
||||
@@ -3,16 +3,22 @@ namespace Engine.Kernel.Scheduling;
|
||||
/// <summary>
|
||||
/// Fixed, kernel-defined, not plugin-extensible — see "Frame stages" in
|
||||
/// docs/kernel-contract.md §8. <see cref="Present"/> is the second addition
|
||||
/// to the original {Update, Render} set (after FixedUpdate was scoped for
|
||||
/// M4), added for M3's editor: engine.render splits its old single
|
||||
/// Clear+Draw+SwapBuffers system into a Render-stage draw and a
|
||||
/// Present-stage swap specifically so engine.editor's ImGui overlay — which
|
||||
/// has to run after the scene is drawn but before the buffers swap — has
|
||||
/// somewhere to register that isn't a race against load order. Not a stage
|
||||
/// added speculatively: SwapBuffers already needed to move somewhere real.
|
||||
/// to the original {Update, Render} set, added for M3's editor: engine.
|
||||
/// render splits its old single Clear+Draw+SwapBuffers system into a
|
||||
/// Render-stage draw and a Present-stage swap specifically so engine.
|
||||
/// editor's ImGui overlay — which has to run after the scene is drawn but
|
||||
/// before the buffers swap — has somewhere to register that isn't a race
|
||||
/// against load order. <see cref="FixedUpdate"/> is the third, arriving
|
||||
/// with M4 exactly as ITime's own doc comment said it would: engine.physics
|
||||
/// steps Box3D here, at ITime.FixedDeltaTime's fixed rate, run zero-or-more
|
||||
/// times per frame by the accumulator Engine.Host drives — see
|
||||
/// Time.ConsumeFixedSteps. Runs before Update each frame it fires, so
|
||||
/// gameplay code reading a Rigidbody's position in Update sees this frame's
|
||||
/// physics result, not last frame's.
|
||||
/// </summary>
|
||||
public enum Stage
|
||||
{
|
||||
FixedUpdate,
|
||||
Update,
|
||||
Render,
|
||||
Present,
|
||||
|
||||
@@ -27,6 +27,29 @@ internal static class SystemAccessScope
|
||||
return new Restore(previous);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears any active scope for the duration of a bulk, whole-world
|
||||
/// operation — GameWorld.Restore is the one real caller. Restore
|
||||
/// rebuilds arbitrary component types from a snapshot via
|
||||
/// GameObject.AddComponent, and it can be invoked from inside a
|
||||
/// running system (a Play/Stop button click handled during Stage.
|
||||
/// Render, say) as easily as from unconstrained editor/host code. This
|
||||
/// class's own doc comment already says editor code and scene
|
||||
/// construction are unconstrained by design; without this, that's only
|
||||
/// true when the call happens to originate outside any system's own
|
||||
/// scope, not because Restore is actually exempt — a real bug, not a
|
||||
/// hypothetical one: entering then exiting Play mode from the editor's
|
||||
/// own Stop button throws, because ExitPlay runs inside EditorPlugin's
|
||||
/// Stage.Render system, which (correctly) never declares Writes<T>()
|
||||
/// for component types it has no compile-time knowledge of.
|
||||
/// </summary>
|
||||
public static IDisposable Suspend()
|
||||
{
|
||||
var previous = Current.Value;
|
||||
Current.Value = null;
|
||||
return new Restore(previous);
|
||||
}
|
||||
|
||||
/// <summary>Querying or fetching a component counts as a read — either
|
||||
/// Reads<T>() or Writes<T>() satisfies it.</summary>
|
||||
public static void CheckRead(Type componentType)
|
||||
|
||||
@@ -113,6 +113,12 @@ public sealed class GameWorld : IWorld
|
||||
|
||||
public void Restore(string snapshot)
|
||||
{
|
||||
// Suspended for the whole rebuild, not just around AddComponent:
|
||||
// Restore is a bulk, whole-world reset regardless of which system
|
||||
// (if any) happened to call it — see SystemAccessScope.Suspend's
|
||||
// own doc comment for why this is a real, not hypothetical, fix.
|
||||
using var _ = SystemAccessScope.Suspend();
|
||||
|
||||
foreach (var root in Roots.ToArray())
|
||||
Destroy(root);
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Engine.Audio;
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Audio.Tests;
|
||||
|
||||
file sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<string> Warnings { get; } = [];
|
||||
public List<string> Errors { get; } = [];
|
||||
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) => Warnings.Add(message);
|
||||
public void Error(string message) => Errors.Add(message);
|
||||
}
|
||||
|
||||
public class AudioWorldTests
|
||||
{
|
||||
private const string TestClip = "assets/test_tone.wav";
|
||||
|
||||
// Always the null backend: real playback needs a real audio device,
|
||||
// which a test run shouldn't depend on or be heard making noise from.
|
||||
// See AudioWorld's own doc comment.
|
||||
private static AudioWorld NewWorld(ILogger log) => new(log, useNullBackend: true);
|
||||
|
||||
[Fact]
|
||||
public void PlayOnAwake_StartsPlaybackAsSoonAsSourceIsSynced()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
go.GetComponent<AudioSource>()!.PlayOnAwake = true;
|
||||
|
||||
audio.Sync(world);
|
||||
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
Assert.Empty(log.Errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithoutPlayOnAwake_DoesNotAutoPlay_ButServicePlayStarts()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
|
||||
audio.Sync(world);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
|
||||
audio.Play(go);
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stop_StopsPlayback()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Sfx");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
|
||||
audio.Play(go);
|
||||
Assert.True(audio.IsPlaying(go));
|
||||
|
||||
audio.Stop(go);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingClip_WarnsAndDoesNotThrow()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Broken");
|
||||
go.AddComponent<AudioSource>().ClipPath = "assets/does_not_exist.wav";
|
||||
|
||||
audio.Sync(world);
|
||||
audio.Sync(world); // a second sync shouldn't re-warn or throw either
|
||||
|
||||
Assert.Single(log.Warnings);
|
||||
Assert.False(audio.IsPlaying(go));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestroyedGameObject_SoundCleanedUp_SubsequentSyncsStillWork()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var audio = NewWorld(log);
|
||||
|
||||
var go = world.CreateGameObject("Temp");
|
||||
go.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
audio.Play(go);
|
||||
|
||||
world.Destroy(go);
|
||||
audio.Sync(world); // should unload the now-orphaned sound, not throw
|
||||
|
||||
// A second GameObject reusing the same clip proves the world is
|
||||
// still in a usable state after the cleanup above.
|
||||
var go2 = world.CreateGameObject("Temp2");
|
||||
go2.AddComponent<AudioSource>().ClipPath = TestClip;
|
||||
audio.Sync(world);
|
||||
audio.Play(go2);
|
||||
|
||||
Assert.True(audio.IsPlaying(go2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<Content Include="../../plugins/engine.audio/Engine.Audio/native/linux-x64/liblingua_audio.so">
|
||||
<Link>liblingua_audio.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<Content Include="../../plugins/engine.audio/Engine.Audio/native/win-x64/lingua_audio.dll">
|
||||
<Link>lingua_audio.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="assets/test_tone.wav">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\plugins\engine.audio\Engine.Audio\Engine.Audio.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Binary file not shown.
@@ -32,6 +32,11 @@
|
||||
the harness's Default ALC, which defeats the point of a leak test. -->
|
||||
<ProjectReference Include="..\..\plugins\sandbox.echo\Sandbox.Echo\Sandbox.Echo.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
|
||||
<!-- Same build-order-only reasoning, for PluginHostTests' Configure-
|
||||
throws-partway-through fixture. -->
|
||||
<ProjectReference Include="..\..\plugins\sandbox.failing-configure\Sandbox.FailingConfigure\Sandbox.FailingConfigure.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Flatten plugin.json + both built DLLs into one directory under this
|
||||
@@ -54,6 +59,15 @@
|
||||
<Link>plugins\sandbox.echo\Sandbox.Echo.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
||||
<None Include="..\..\plugins\sandbox.failing-configure\plugin.json">
|
||||
<Link>plugins\sandbox.failing-configure\plugin.json</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="..\..\plugins\sandbox.failing-configure\Sandbox.FailingConfigure\bin\$(Configuration)\$(TargetFramework)\Sandbox.FailingConfigure.dll">
|
||||
<Link>plugins\sandbox.failing-configure\Sandbox.FailingConfigure.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.Events;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.Services;
|
||||
using Engine.Kernel.World;
|
||||
using Sandbox.Echo.Contracts;
|
||||
|
||||
namespace Engine.ConformanceHarness;
|
||||
|
||||
/// <summary>
|
||||
/// Found by independent review, not by any test: PluginHost.Load didn't
|
||||
/// roll back anything when Configure threw partway through — Schedule.Add/
|
||||
/// Events.Subscribe calls it already made stayed registered forever, and
|
||||
/// the ALC it loaded into was never unloaded. Sandbox.FailingConfigure
|
||||
/// exists specifically to load real, into a real collectible ALC, and
|
||||
/// throw after registering a system, exactly reproducing that shape.
|
||||
///
|
||||
/// The system it registers increments a shared Ping component — a real,
|
||||
/// deterministic side effect. An earlier version of this test tried to
|
||||
/// prove the rollback via AssemblyLoadContext.All instead (did the failed
|
||||
/// load's ALC get collected?), but that passed even against the
|
||||
/// deliberately-reverted buggy PluginHost.Load: the ALC turned out to get
|
||||
/// collected either way once its only references (local variables inside
|
||||
/// Load) went out of scope, regardless of whether Schedule/EventBus still
|
||||
/// held onto its assembly. Watching whether the dangling system still
|
||||
/// fires is the thing that actually distinguishes rolled-back from not.
|
||||
/// </summary>
|
||||
public class FailedConfigureRollbackTests
|
||||
{
|
||||
private static string PluginDirectory =>
|
||||
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.failing-configure");
|
||||
|
||||
private static PluginHost NewHost(Schedule schedule, GameWorld world) =>
|
||||
new(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
|
||||
|
||||
[Fact]
|
||||
public void Load_Rethrows_The_Configure_Exception()
|
||||
{
|
||||
var host = NewHost(new Schedule(), new GameWorld());
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Fails_The_Same_Way_On_Retry_Instead_Of_Already_Loaded()
|
||||
{
|
||||
var host = NewHost(new Schedule(), new GameWorld());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
|
||||
// Before the fix, _loaded never got an entry either way — this
|
||||
// alone wouldn't have caught the bug — but it's still the right
|
||||
// thing to be true: retrying isn't "already loaded," it fails the
|
||||
// same way every time.
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
Assert.Equal("deliberate failure for PluginHostTests", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLoaded_Is_False_After_A_Failed_Load_So_Retry_Does_Not_Need_Unload_First()
|
||||
{
|
||||
var host = NewHost(new Schedule(), new GameWorld());
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
Assert.False(host.IsLoaded("sandbox.failing-configure"));
|
||||
|
||||
// Mirrors Engine.Host's own "r <id>" handler: only Unload first if
|
||||
// IsLoaded says so. Before that check existed, this exact retry
|
||||
// sequence threw "Plugin 'sandbox.failing-configure' is not
|
||||
// loaded" instead of ever reaching Load again.
|
||||
if (host.IsLoaded("sandbox.failing-configure"))
|
||||
host.Unload("sandbox.failing-configure");
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_Removes_The_System_Configure_Registered_Before_Throwing()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var schedule = new Schedule();
|
||||
var host = NewHost(schedule, world);
|
||||
|
||||
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => host.Load(PluginDirectory));
|
||||
|
||||
// Without rollback, the Tick system FailingConfigurePlugin
|
||||
// registered before throwing is still sitting in Schedule and
|
||||
// fires here, incrementing Count. With rollback (schedule.
|
||||
// RemoveAllFrom in the catch block), it's gone, and this does
|
||||
// nothing.
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
Assert.Equal(0, ping.Count);
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,37 @@ public class TimeTests
|
||||
Assert.Equal(0.75, time.ElapsedTime, 3);
|
||||
Assert.Equal(2, time.FrameCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumeFixedSteps_BelowOneStep_ReturnsZero()
|
||||
{
|
||||
var time = new Time();
|
||||
|
||||
// FixedDeltaTime is 1/50 = 0.02s; a 60fps-ish frame is shorter.
|
||||
time.Tick(0.01f);
|
||||
|
||||
Assert.Equal(0, time.ConsumeFixedSteps());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumeFixedSteps_ConsumesWholeStepsAndKeepsRemainder()
|
||||
{
|
||||
var time = new Time();
|
||||
|
||||
time.Tick(0.05f); // 2.5 steps' worth
|
||||
Assert.Equal(2, time.ConsumeFixedSteps());
|
||||
|
||||
time.Tick(0.03f); // remainder 0.01 + 0.03 = 0.04 -> 2 more steps
|
||||
Assert.Equal(2, time.ConsumeFixedSteps());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConsumeFixedSteps_CapsAtFiveEvenAfterAHugeStall()
|
||||
{
|
||||
var time = new Time();
|
||||
|
||||
time.Tick(10f); // a debugger pause, not a real frame
|
||||
|
||||
Assert.Equal(5, time.ConsumeFixedSteps());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
|
||||
namespace Engine.Kernel.Tests;
|
||||
@@ -103,4 +104,30 @@ public class WorldSnapshotTests
|
||||
stopwatch.ElapsedMilliseconds < 100,
|
||||
$"Snapshot + Restore of 300 GameObjects took {stopwatch.ElapsedMilliseconds} ms, expected < 100 ms.");
|
||||
}
|
||||
|
||||
// A real bug, caught by hand: entering then exiting Play mode via
|
||||
// engine.editor's own Stop button threw InvalidOperationException,
|
||||
// because ExitPlay runs Restore from inside EditorPlugin's Stage.
|
||||
// Render system — which correctly declares no Reads/Writes at all, it
|
||||
// has no compile-time knowledge of QuadRenderer or any other game's
|
||||
// component types — and Restore rebuilds those very types via
|
||||
// AddComponent. Every other test in this file calls Restore directly,
|
||||
// outside any system's scope, which is exactly why none of them caught
|
||||
// this. This one reproduces the real call shape: a system with no
|
||||
// declared access, calling Restore, run through the real Schedule.
|
||||
[Fact]
|
||||
public void Restore_Called_From_A_System_With_No_Declared_Access_Does_Not_Throw()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var go = world.CreateGameObject("Hero");
|
||||
go.AddComponent<Health>().Value = 100;
|
||||
var snapshot = world.Snapshot();
|
||||
|
||||
var schedule = new Schedule();
|
||||
schedule.Add(Stage.Update, (IWorld w) => w.Restore(snapshot)); // no Reads/Writes — like EditorPlugin's DrawUi
|
||||
|
||||
var exception = Record.Exception(() => schedule.RunStage(Stage.Update, world));
|
||||
|
||||
Assert.Null(exception);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Same native shim Engine.Physics itself ships, same per-OS split —
|
||||
see that project's own Content items. -->
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Linux'))">
|
||||
<Content Include="../../plugins/engine.physics/Engine.Physics/native/linux-x64/liblingua_physics.so">
|
||||
<Link>liblingua_physics.so</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<Content Include="../../plugins/engine.physics/Engine.Physics/native/win-x64/lingua_physics.dll">
|
||||
<Link>lingua_physics.dll</Link>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\plugins\engine.physics\Engine.Physics\Engine.Physics.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,219 @@
|
||||
using System.Numerics;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics;
|
||||
using Engine.Physics.Contracts;
|
||||
|
||||
namespace Engine.Physics.Tests;
|
||||
|
||||
file sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<string> Warnings { get; } = [];
|
||||
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) => Warnings.Add(message);
|
||||
public void Error(string message) { }
|
||||
}
|
||||
|
||||
public class PhysicsWorldTests
|
||||
{
|
||||
private static GameObject CreateGround(GameWorld world)
|
||||
{
|
||||
var go = world.CreateGameObject("Ground");
|
||||
go.Transform = Transform.Identity;
|
||||
go.AddComponent<Rigidbody>().Type = BodyType.Static;
|
||||
go.AddComponent<BoxCollider>().HalfExtents = new Vector3(10f, 0.5f, 10f);
|
||||
return go;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DynamicBox_FallsAndSettlesOnStaticGround()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
CreateGround(world);
|
||||
|
||||
var box = world.CreateGameObject("FallingBox");
|
||||
box.Transform = Transform.Identity;
|
||||
box.Transform.LocalPosition = new Vector3(0, 5, 0);
|
||||
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
box.AddComponent<BoxCollider>().HalfExtents = new Vector3(0.5f, 0.5f, 0.5f);
|
||||
|
||||
for (var i = 0; i < 150; i++)
|
||||
{
|
||||
physics.Sync(world);
|
||||
physics.Step(1f / 50f);
|
||||
}
|
||||
|
||||
// Ground half-height 0.5 + box half-height 0.5 = rests at y=1.
|
||||
Assert.Equal(1f, box.Transform.LocalPosition.Y, 1);
|
||||
Assert.Empty(log.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DynamicSphere_FallsAndSettlesOnStaticGround()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
CreateGround(world);
|
||||
|
||||
var sphere = world.CreateGameObject("FallingSphere");
|
||||
sphere.Transform = Transform.Identity;
|
||||
sphere.Transform.LocalPosition = new Vector3(0, 5, 0);
|
||||
sphere.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
sphere.AddComponent<SphereCollider>().Radius = 0.5f;
|
||||
|
||||
for (var i = 0; i < 150; i++)
|
||||
{
|
||||
physics.Sync(world);
|
||||
physics.Step(1f / 50f);
|
||||
}
|
||||
|
||||
Assert.Equal(1f, sphere.Transform.LocalPosition.Y, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RigidbodyWithoutCollider_WarnsOnceAndDoesNotThrow()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
var go = world.CreateGameObject("NoShape");
|
||||
go.Transform = Transform.Identity;
|
||||
go.AddComponent<Rigidbody>();
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
physics.Sync(world);
|
||||
physics.Step(1f / 50f);
|
||||
}
|
||||
|
||||
Assert.Single(log.Warnings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DestroyedGameObject_BodyCleanedUp_SubsequentStepsStillWork()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
CreateGround(world);
|
||||
|
||||
var box = world.CreateGameObject("Temp");
|
||||
box.Transform = Transform.Identity;
|
||||
box.Transform.LocalPosition = new Vector3(0, 5, 0);
|
||||
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
box.AddComponent<BoxCollider>();
|
||||
|
||||
physics.Sync(world);
|
||||
physics.Step(1f / 50f);
|
||||
|
||||
world.Destroy(box);
|
||||
|
||||
// Should not throw even though the body backing a now-destroyed
|
||||
// GameObject still existed in the tracking table until this Sync.
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
physics.Sync(world);
|
||||
physics.Step(1f / 50f);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyLinearImpulse_ChangesVelocity()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(Vector3.Zero, log); // no gravity, isolate the impulse
|
||||
|
||||
var box = world.CreateGameObject("Box");
|
||||
box.Transform = Transform.Identity;
|
||||
box.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
box.AddComponent<BoxCollider>();
|
||||
|
||||
physics.Sync(world); // create the body before applying an impulse to it
|
||||
|
||||
physics.ApplyLinearImpulse(box, new Vector3(5, 0, 0), wake: true);
|
||||
|
||||
var velocity = physics.GetLinearVelocity(box);
|
||||
Assert.True(velocity.X > 0f, $"expected positive X velocity after impulse, got {velocity.X}");
|
||||
}
|
||||
|
||||
// Sync's old early-return compared _bodies.Count to live.Count, not
|
||||
// their contents — a real leak found by independent review, not by
|
||||
// any of the tests above (all of them either never remove a
|
||||
// GameObject, or do so in a way that changes the count). Both tests
|
||||
// below reproduce the review's own two scenarios and assert on
|
||||
// Native.Lingua_GetBodyCount() directly: the native table's own count
|
||||
// is what actually leaks, and PhysicsWorld's C#-side _bodies
|
||||
// dictionary alone can't prove it didn't.
|
||||
|
||||
[Fact]
|
||||
public void Sync_DestroysStaleBody_EvenWhenLiveCountStaysTheSame()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
var a = world.CreateGameObject("A");
|
||||
a.Transform = Transform.Identity;
|
||||
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
a.AddComponent<BoxCollider>();
|
||||
|
||||
// B has a Rigidbody but no collider — it counts toward "live"
|
||||
// every Sync (any Rigidbody does) but never gets a native body.
|
||||
var b = world.CreateGameObject("B");
|
||||
b.Transform = Transform.Identity;
|
||||
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
|
||||
physics.Sync(world);
|
||||
Assert.Equal(1, Native.Lingua_GetBodyCount());
|
||||
|
||||
world.Destroy(a);
|
||||
// live = {B} (1), stale _bodies = {A} (1) — same count as before
|
||||
// destroying A, which is exactly what let the old bug's early
|
||||
// return skip cleanup.
|
||||
physics.Sync(world);
|
||||
|
||||
Assert.Equal(0, Native.Lingua_GetBodyCount());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sync_DestroysStaleBody_AcrossARestore()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var log = new RecordingLogger();
|
||||
using var physics = new PhysicsWorld(new Vector3(0, -10, 0), log);
|
||||
|
||||
var a = world.CreateGameObject("A");
|
||||
a.Transform = Transform.Identity;
|
||||
a.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
a.AddComponent<BoxCollider>();
|
||||
|
||||
var b = world.CreateGameObject("B"); // Rigidbody, no collider
|
||||
b.Transform = Transform.Identity;
|
||||
b.AddComponent<Rigidbody>().Type = BodyType.Dynamic;
|
||||
|
||||
physics.Sync(world);
|
||||
Assert.Equal(1, Native.Lingua_GetBodyCount());
|
||||
|
||||
// Restore destroys A and B and rebuilds fresh instances A'/B' from
|
||||
// the snapshot. The next Sync creates A' (a new body) while old
|
||||
// A's body is now orphaned — _bodies briefly holds {A, A'} (2)
|
||||
// and live holds {A', B'} (2), the same count, which is exactly
|
||||
// what the old bug's early return let slip through as "nothing
|
||||
// stale to clean up."
|
||||
var snapshot = world.Snapshot();
|
||||
world.Restore(snapshot);
|
||||
physics.Sync(world);
|
||||
|
||||
// Exactly one live native body (A'), not two (leaked old A + A').
|
||||
Assert.Equal(1, Native.Lingua_GetBodyCount());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../samples/PhysicsDemo/GamePlugins/physics-demo-game/PhysicsDemoGame.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,176 @@
|
||||
using System.Numerics;
|
||||
using Engine.Audio.Contracts;
|
||||
using Engine.Input.Contracts;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.Events;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.Services;
|
||||
using Engine.Kernel.World;
|
||||
using Engine.Physics.Contracts;
|
||||
using PhysicsDemoGame;
|
||||
using Silk.NET.Input;
|
||||
|
||||
namespace PhysicsDemoGame.Tests;
|
||||
|
||||
// No real window, no real GLFW keyboard, no real Box3D/miniaudio needed to
|
||||
// test this plugin's OWN logic (spawn-on-press, landed-detection) — only
|
||||
// whether it calls its three service dependencies correctly. Real OS-level
|
||||
// key injection isn't available in this sandbox (no xdotool/ydotool under
|
||||
// Wayland) — a fake IEngineInput the test drives directly is the honest
|
||||
// substitute, not a compromise: it exercises PhysicsDemoGamePlugin's real
|
||||
// Configure()/Tick() through the real Schedule (so SystemAccessScope's
|
||||
// Writes<Rigidbody/BoxCollider/CubeRenderer>() declarations are genuinely
|
||||
// checked), with IPhysicsService/IAudioService swapped for controllable
|
||||
// fakes since engine.physics/engine.audio's own correctness is already
|
||||
// covered by their own test projects.
|
||||
|
||||
internal sealed class FakeInput : IEngineInput
|
||||
{
|
||||
public bool SpaceDown;
|
||||
public bool IsKeyDown(Key key) => key == Key.Space && SpaceDown;
|
||||
public IInputContext Native => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
internal sealed class FakePhysics : IPhysicsService
|
||||
{
|
||||
public readonly Dictionary<GameObject, Vector3> Velocity = [];
|
||||
|
||||
public void ApplyLinearImpulse(GameObject go, Vector3 impulse, bool wake = true) { }
|
||||
public Vector3 GetLinearVelocity(GameObject go) => Velocity.GetValueOrDefault(go, Vector3.Zero);
|
||||
public void SetLinearVelocity(GameObject go, Vector3 velocity) => Velocity[go] = velocity;
|
||||
}
|
||||
|
||||
internal sealed class FakeAudio : IAudioService
|
||||
{
|
||||
public readonly List<GameObject> Played = [];
|
||||
|
||||
public void Play(GameObject go) => Played.Add(go);
|
||||
public void Stop(GameObject go) { }
|
||||
public bool IsPlaying(GameObject go) => false;
|
||||
}
|
||||
|
||||
internal sealed class NullLogger : ILogger
|
||||
{
|
||||
public void Info(string message) { }
|
||||
public void Warn(string message) { }
|
||||
public void Error(string message) { }
|
||||
}
|
||||
|
||||
internal sealed class TestPluginContext(
|
||||
IWorld world, IServiceRegistry services, ISchedule schedule, IEventBus events, ITime time) : IPluginContext
|
||||
{
|
||||
public IWorld World { get; } = world;
|
||||
public IServiceRegistry Services { get; } = services;
|
||||
public ISchedule Schedule { get; } = schedule;
|
||||
public IEventBus Events { get; } = events;
|
||||
public ILogger Log { get; } = new NullLogger();
|
||||
public ITime Time { get; } = time;
|
||||
}
|
||||
|
||||
public class PhysicsDemoGamePluginTests
|
||||
{
|
||||
private static (GameWorld World, Schedule Schedule, FakeInput Input, FakePhysics Physics, FakeAudio Audio) Setup()
|
||||
{
|
||||
var world = new GameWorld();
|
||||
var schedule = new Schedule();
|
||||
var services = new ServiceRegistry();
|
||||
var input = new FakeInput();
|
||||
var physics = new FakePhysics();
|
||||
var audio = new FakeAudio();
|
||||
|
||||
services.Provide<IEngineInput>(input);
|
||||
services.Provide<IPhysicsService>(physics);
|
||||
services.Provide<IAudioService>(audio);
|
||||
|
||||
var ctx = new TestPluginContext(world, services, schedule, new EventBus(), new Time());
|
||||
new global::PhysicsDemoGame.PhysicsDemoGamePlugin().Configure(ctx);
|
||||
|
||||
return (world, schedule, input, physics, audio);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PressingSpace_SpawnsExactlyOneBox_EvenIfHeldAcrossFrames()
|
||||
{
|
||||
var (world, schedule, input, _, _) = Setup();
|
||||
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world); // press
|
||||
schedule.RunStage(Stage.Update, world); // still held — no second spawn
|
||||
|
||||
Assert.Single(world.Roots);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReleasingAndPressingAgain_SpawnsASecondBox()
|
||||
{
|
||||
var (world, schedule, input, _, _) = Setup();
|
||||
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
input.SpaceDown = false;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
Assert.Equal(2, world.Roots.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpawnedBox_HasRigidbodyBoxColliderAndCubeRenderer()
|
||||
{
|
||||
var (world, schedule, input, _, _) = Setup();
|
||||
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
var box = Assert.Single(world.Roots);
|
||||
Assert.NotNull(box.GetComponent<Rigidbody>());
|
||||
Assert.NotNull(box.GetComponent<BoxCollider>());
|
||||
Assert.Equal(BodyType.Dynamic, box.GetComponent<Rigidbody>()!.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoxThatNeverActuallyFalls_DoesNotTriggerTheBounceSound()
|
||||
{
|
||||
var (world, schedule, input, physics, audio) = Setup();
|
||||
var bounce = world.CreateGameObject("Bounce");
|
||||
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
var box = world.Roots.First(go => go != bounce);
|
||||
|
||||
// Spawned at rest (velocity never set below the falling threshold)
|
||||
// — must not be mistaken for "landed."
|
||||
for (var i = 0; i < 5; i++)
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
|
||||
Assert.Empty(audio.Played);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BoxThatFallsThenSettles_TriggersTheBounceSoundExactlyOnce()
|
||||
{
|
||||
var (world, schedule, input, physics, audio) = Setup();
|
||||
var bounce = world.CreateGameObject("Bounce");
|
||||
|
||||
input.SpaceDown = true;
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
input.SpaceDown = false;
|
||||
var box = world.Roots.First(go => go != bounce);
|
||||
|
||||
physics.Velocity[box] = new Vector3(0, -5, 0); // falling fast
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
Assert.Empty(audio.Played);
|
||||
|
||||
physics.Velocity[box] = new Vector3(0, 0, 0); // settled
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
Assert.Single(audio.Played);
|
||||
Assert.Same(bounce, audio.Played[0]);
|
||||
|
||||
// Still resting on later frames — must not re-trigger.
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
schedule.RunStage(Stage.Update, world);
|
||||
Assert.Single(audio.Played);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user