diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml deleted file mode 100644 index e27d317..0000000 --- a/.gitea/workflows/ci.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: CI - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13.1"] - - steps: - - uses: actions/checkout@v3 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e .[dev] - - - name: Run linters - run: | - black . --check - isort . --check -# flake8 . - - - name: Run tests - run: | - pytest tests/ --cov=src --cov-report=xml - -# Deployment job - uncomment and configure when PyPI uploads are needed -# This job will build and upload your package to PyPI when merging to main -# -# deploy: -# needs: test -# runs-on: ubuntu-latest -# if: github.event_name == 'push' && github.ref == 'refs/heads/main' -# -# steps: -# - uses: actions/checkout@v3 -# -# - name: Set up Python -# uses: actions/setup-python@v3 -# with: -# python-version: "3.8" -# -# - name: Build and publish -# env: -# TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} -# TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} -# run: | -# pip install build twine -# python -m build -# twine upload dist/* diff --git a/.gitignore b/.gitignore index d7ba603..4dccd8f 100644 --- a/.gitignore +++ b/.gitignore @@ -45,9 +45,22 @@ unittest/ # Distribution dist/ build/ +docs/ *.exe +output/ # Logs logs/ *.log livenotes.txt +AGENT.md +.pyrightignore +.gitignore +sandpypi-old.7z +.sdt + +# Builds +bin/ +obj/ +Sand.Core\artifacts +Sand.App\artifacts diff --git a/.pre-commit-config.yaml.disabled b/.pre-commit-config.yaml.disabled deleted file mode 100644 index 7d9c87a..0000000 --- a/.pre-commit-config.yaml.disabled +++ /dev/null @@ -1,30 +0,0 @@ -#repos: -#- repo: https://github.com/psf/black -# rev: 23.12.1 -# hooks: -# - id: black -# language_version: python3 -# args: ["--line-length", "79"] - -#- repo: https://github.com/pycqa/isort -# rev: 5.13.2 -# hooks: -# - id: isort -# args: ["--profile", "black", "--filter-files"] - -#- repo: https://github.com/pre-commit/pre-commit-hooks -# rev: v4.5.0 -# hooks: -# - id: trailing-whitespace -# - id: end-of-file-fixer -# - id: check-yaml -# - id: check-added-large-files - -#- repo: local -# hooks: -# - id: pytest -# name: pytest -# entry: pytest -# language: system -# pass_filenames: false -# always_run: false diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 7774467..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - - { - "name": "Python Debugger: Current File", - "type": "debugpy", - "request": "launch", - "program": "${file}", - "console": "integratedTerminal" - } - ] -} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0d3d120..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "python.linting.pylintArgs": [ - "--disable=E1101", - "--ignored-modules=pygame" - ], - "cody.agentic.context.experimentalShell": true -} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5b918b1 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,5 @@ + + + $(DefaultItemExcludes);**\artifacts\** + + diff --git a/README.md b/README.md index ab64952..b00c779 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,60 @@ -# ***Falling Sand Simulation Concept in Python*** +# Sand C# -mostly a concept in python for falling sand simulation -i guess the goal is to make a python version of the powder toy which is a falling sand sim -the code is not finished yet, but i will update it as i go. +This repo is a C# falling-sand / falling-everything engine and app shell. The first game is a falling-sand game, but the longer-term direction is a broader simulation-heavy engine with larger worlds. -## Main Features +## Repo layout -- Particle Physics - - Gravity and wind effects (maybe on the wind zones) - - Temperature dynamics - - State transitions (melting, freezing, evaporation) - -- Particle Interactions - - Collision detection - - Chemical reactions (e.g., water + sand = wet sand, Lava + lower temperature = molten rock = rock) - - Heat transfer between particles (e.g., Things seem to cool off as for heating up that's a different thing) - -- Special Effects - - Fire propagation sorta - - Smoke generation - - Liquid spreading (Could be improved ) - -- Optimization Features - - Spatial partitioning grid (to reduce calculations) - - Dormant particle tracking (to reduce unnecessary calculations) - - Batch processing (to reduce unnecessary calculations) - - Static User Interface (to reduce unnecessary calculations) +- `Sand.Core/`, `Sand.App/`, `Sand.Tests/`: production dense backend, app shell, and regression tests +- `Sand.ChunkPrototype/`, `Sand.ChunkPrototype.Tests/`: experimental chunk backend and its tests +- `Sand.Benchmarks/`: dense/chunk benchmark runner with app-sized and snapshot modes +- `advchksys/`: imported chunk-system library kept as a support/reference asset +- `docs/csharp-parity-audit.md`: current content/runtime mapping audit +- `docs/chunk-engine-progress.md`: chunk program log, blockers, and milestone measurements +- `ROADMAP.md`: authoritative implementation order and promotion gates -### **Current Features** +## Current status -| **Working** | **Partial** | **Not Working/Implemented** | -| ----------------- | ----------------------------- | --------------------------- | -| Gravity | Gas | Zoom | -| Liquid/Solid | Liquid | Pause | -| Particle Data | Fire/Flame | Pressure | -| Brush Sizes | Particle Basic Interactions | explosiveness | -| Debugging Info | Particle Heat interactions | conductiveness | -| Menus and Buttons | Ambient Heat | | -| | Wind | | -| | Core Physics and Interactions | | -| | Core Rendering System | | +The dense backend is still the production/reference path. It already supports fixed-timestep stepping, pressure, thermal, explosions, tool fields, and the current app/debug shell. -#### **Controls** +The chunk backend also exists in the main app as an experimental backend, but it is not yet faster than dense in the full game shell. Current chunk work is focused on stabilization, optimization, truthful measurement, and subsystem-by-subsystem parity growth. -| Key | Action | -| ------------------- | ------------------------------ | -| Z | Zoom window | -| ESC | Exit Program | -| C | Clear Screen | -| Space | Pause Simulation | -| Mouse 1 {Left} | Spawn Particle with brush size | -| Mouse 3 {Right} | Erase Particles at cursor | -| Mouse 2 {Middle} | Spawn Particle at cursor | -| Mouse Wheel Up/Down | Change Brush size | +Real app runs are the source of truth for chunk progress. Benchmarks support decisions, but they do not replace interactive testing. + +## Run + +Dense app: + +```powershell +Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue +dotnet run --project .\Sand.App\Sand.App.csproj -c Release +``` + +Chunk app: + +```powershell +$env:SAND_BACKEND='chunk' +dotnet run --project .\Sand.App\Sand.App.csproj -c Release +``` + +Clear the override after chunk testing: + +```powershell +Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue +``` + +Benchmarks: + +```powershell +dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode app-sized +``` + +```powershell +dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes +``` + +## Validation + +```powershell +dotnet test Sand.sln +dotnet test .\Sand.ChunkPrototype.Tests\Sand.ChunkPrototype.Tests.csproj -c Release +``` diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..ab5d007 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,170 @@ +# Roadmap + +This file is the single roadmap for the C# engine, the first falling-sand game, and the experimental chunk-engine program. + +Status labels: + +- `[x]` done +- `[-]` partial / in progress +- `[ ]` not started + +## Current State + +### Repo and runtime + +- `[x]` C# code split into `Sand.Core`, `Sand.App`, and `Sand.Tests` +- `[x]` dense production backend with fixed-timestep stepping, pressure, thermal, explosions, and tool fields +- `[x]` main app backend seam with dense default and chunk experimental selection via `SAND_BACKEND` +- `[x]` benchmark harness under `Sand.Benchmarks` +- `[x]` chunk residency/prototype runtime under `Sand.ChunkPrototype` +- `[x]` imported `advchksys` kept as a support/prototype asset, not the production runtime substrate + +### Chunk status + +- `[x]` chunk residency scaffold exists +- `[x]` chunk backend is integrated into the main app as an experimental backend +- `[x]` chunk motion-regression tests cover wake-on-paint and shimmer issues found so far +- `[x]` active-move bottleneck instrumentation completed +- `[-]` high-motion chunk workloads are materially more stable in the real main app shell, and long-press gas overload no longer easily death-spirals the app, but mixed-scene runtime hotspots remain +- `[x]` chunk runtime can attribute active-frame cost to movement / runtime / activation / render buckets +- `[-]` live-app branch metrics now identify mixed-scene full-runtime work across solids, liquids, and gases as the main remaining single-thread hotspot after gas-stress stabilization +- `[ ]` chunk backend competitive in the real main app shell +- `[ ]` chunk-local page-step architecture fully optimized +- `[ ]` chunk dirty-page render path fully paying off in the real app +- `[x]` chunk metrics/progress log added in [`docs/chunk-engine-progress.md`](/f:/Documents/Dev/sandpypi/docs/chunk-engine-progress.md) + +## Parity and Engine Gaps + +### Dense/runtime gaps + +- `[-]` movement-property tuning from metadata (`velocity`, `friction`, `viscosity`, `heat_capacity`) +- `[-]` pressure/breakage tuning polish +- `[ ]` deterministic snapshot capture and comparison harness +- `[ ]` richer UI/debug overlays beyond current parity level +- `[ ]` accelerator implementation behind `ISimulationAccelerator` + +### Chunk/runtime gaps + +- `[ ]` chunk-local contiguous hot-path storage replacing the current prototype-level iteration costs +- `[ ]` chunk-local active-band stepping and better neighbor wake scheduling +- `[ ]` chunk-aware frame generation that clearly reduces bytes touched on localized scenes +- `[ ]` parity ladder for reactions, phase transitions, thermal, burning, and explosions +- `[ ]` deterministic dense-vs-chunk snapshot parity for ported subsystems + +## Next Milestones + +### Dense Track + +- `[-]` continue movement-property and force tuning from metadata +- `[ ]` add snapshot capture/comparison harness for named scenes +- `[ ]` keep regression coverage growing around perf-sensitive fixes +- `[ ]` stage the first accelerator backend only after snapshot coverage exists + +### Chunk Engine Program + +#### Chunk Program A: Truthful measurement and tracking + +- `[x]` add dated chunk progress / metrics log +- `[x]` add app-sized benchmark mode matching the main app sim dimensions +- `[x]` record first dense-vs-chunk app baseline +- `[x]` split chunk measurements into clearer step-core / activation / render buckets +- `[ ]` keep milestone-relevant benchmark and app results logged in the progress doc + +#### Chunk Program B: Step-core stabilization + +- `[x]` remove wake-on-paint settling artifacts +- `[x]` remove trapped liquid/gas shimmer regressions currently covered by tests +- `[x]` add chunk motion-regression suite +- `[-]` continue stabilizing gas/liquid/solid motion under mixed tool-field stress + +#### Chunk Program C: Chunk-local architecture rewrite + +- `[x]` remove LINQ / global particle sorting from the chunk step hot path +- `[x]` introduce chunk-local page storage types and scheduler scaffolding +- `[-]` step active pages using dirty-band guidance +- `[x]` wake neighbor pages on border writes +- `[ ]` reduce remaining page-lookup and page-render overhead in real app workloads + +#### Chunk Program D: Rendering + stats + +- `[-]` dirty-page / dirty-chunk frame generation path +- `[x]` extend shared chunk frame stats +- `[x]` surface chunk workload metrics in the app debug overlay +- `[ ]` make localized chunk rendering wins clearly visible in app-sized measurements + +#### Chunk Program E: Active-Move Optimization Gate + +- `[x]` add move-attempt / move-result / stalled-motion metrics +- `[x]` add runtime bucket timing for movement / runtime / activation / field decay / render +- `[x]` add active-motion benchmark scenes and app procedures +- `[-]` reduce chunk cost on `10k-15k` simultaneous move frames +- `[x]` verify chunk remains stable under gas-heavy active scenes enough to stop easy long-press collapse +- `[x]` document high-motion baselines and improvements in the progress log +- `[-]` use the stabilized gas/app baseline to reduce remaining mixed-scene full-runtime cost before resuming parity work + +#### Chunk Program F: Feature parity ladder + +- `[ ]` base motion parity improvements +- `[ ]` chunk force / pressure tuning +- `[ ]` common-material phase transitions +- `[ ]` core local reactions +- `[ ]` thermal subset +- `[ ]` burning/effect subset +- `[ ]` explosion / breakage subset + +#### Chunk Program G: Promotion gate + +- `[ ]` sparse app workload beats dense by `>=25%` +- `[ ]` mixed app workload beats dense by `>=10%` +- `[ ]` dense-like workload remains within `15%` +- `[ ]` snapshot suite passes for all ported subsystems + +## Suggested Order + +1. add active-move instrumentation and logging +2. establish dense vs chunk high-motion baselines in the real app shell +3. reduce chunk active-move overhead in the step core +4. confirm gains on gas-heavy and continuous-paint scenes +5. reduce remaining mixed-scene full-runtime hotspots from the stabilized single-thread baseline +6. plan multithreaded architecture from the stabilized single-thread baseline +7. only then resume chunk parity expansion and deeper engine storage promotion + +## Commands + +Dense app: + +```powershell +Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue +dotnet run --project .\Sand.App\Sand.App.csproj -c Release +``` + +Chunk app: + +```powershell +$env:SAND_BACKEND='chunk' +dotnet run --project .\Sand.App\Sand.App.csproj -c Release +``` + +Benchmarks: + +```powershell +dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode app-sized +``` + +```powershell +dotnet run --project .\Sand.Benchmarks\Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes +``` + +## Notes + +- Dense remains the production/reference backend while chunk continues as the experimental engine lane. +- The current chunk bottleneck is high simultaneous movement volume, not simple on-screen particle count. +- Gas-stress long-press collapse is now materially harder to trigger in the main app after overload control and gas-path tuning. +- The main remaining single-thread hotspot is mixed-scene full-runtime work across solids, liquids, and gases. +- Recent live-app runs show the stable-gas case is now mostly under control; the next optimization passes should target mixed runtime-chain cost rather than reopening gas-specific hacks. +- Parity work is paused behind the active-move optimization gate. +- `moves/frame` and FPS are both required signals; benchmark-only wins are not enough by themselves. +- No particle-id-specific throttles are planned; chunk optimizations need to generalize across gas/runtime-heavy materials. +- Multithreaded architecture is a follow-on goal only after the single-thread chunk step is stable, measured, and behaviorally trustworthy. +- The chunk backend is now an engine program, not a one-off feasibility toy, but it still has to beat dense under real workloads before promotion. +- `advchksys` remains a support library and reference asset, not the final Sand engine storage contract. diff --git a/Sand.App/AppSimulationFrameStats.cs b/Sand.App/AppSimulationFrameStats.cs new file mode 100644 index 0000000..443adaa --- /dev/null +++ b/Sand.App/AppSimulationFrameStats.cs @@ -0,0 +1,39 @@ +namespace Sand.App; + +internal sealed class AppSimulationFrameStats +{ + public int Frame { get; set; } + public int ProcessedCells { get; set; } + public int ParticleCount { get; set; } + public int MinActiveX { get; set; } + public int MinActiveY { get; set; } + public int MaxActiveX { get; set; } + public int MaxActiveY { get; set; } + public int LoadedChunkCount { get; set; } + public int ActiveChunkCount { get; set; } + public int DirtyChunkCount { get; set; } + public int SteppedChunkCount { get; set; } + public int SleepingChunkCount { get; set; } + public int FieldPageCount { get; set; } + public int MoveAttemptCount { get; set; } + public int VerticalMoveAttemptCount { get; set; } + public int DiagonalMoveAttemptCount { get; set; } + public int LateralMoveAttemptCount { get; set; } + public int SuccessfulMoveCount { get; set; } + public int SwapAttemptCount { get; set; } + public int StalledMovableCount { get; set; } + public int MovementOnlyFastPathCount { get; set; } + public int FullRuntimeStepCount { get; set; } + public int FullRuntimeSolidCount { get; set; } + public int FullRuntimeLiquidCount { get; set; } + public int FullRuntimeGasCount { get; set; } + public int MovedParticleCount { get; set; } + public int SwappedParticleCount { get; set; } + public int VisualDirtyPageCount { get; set; } + public long FrameBuildBytesTouched { get; set; } + public long ActivationTimeMicroseconds { get; set; } + public long MovementTimeMicroseconds { get; set; } + public long RuntimeTimeMicroseconds { get; set; } + public long FieldDecayTimeMicroseconds { get; set; } + public long RenderTimeMicroseconds { get; set; } +} diff --git a/Sand.App/ChunkPrototypeSimulationBackend.cs b/Sand.App/ChunkPrototypeSimulationBackend.cs new file mode 100644 index 0000000..f2314ab --- /dev/null +++ b/Sand.App/ChunkPrototypeSimulationBackend.cs @@ -0,0 +1,562 @@ +using Sand.ChunkPrototype; +using Sand.Core; + +namespace Sand.App; + +internal sealed class ChunkPrototypeSimulationBackend : ISimulationBackend +{ + private readonly PrototypeSparseSandAdapter _adapter; + private readonly SimulationSettings _settings; + private readonly ParticleLibrary _library; + private readonly Dictionary _particleProfiles; + private readonly PrototypeParticle _wallParticle; + private readonly AppSimulationFrameStats _frameStats = new(); + private int _frame; + private int _trimCounter; + + public ChunkPrototypeSimulationBackend(int width, int height, int particleSize, IParticleLibrary library, SimulationSettings settings) + { + _adapter = new PrototypeSparseSandAdapter(width, height, settings.AmbientTemperature); + _settings = settings; + _library = library as ParticleLibrary ?? throw new ArgumentException("Expected ParticleLibrary implementation.", nameof(library)); + _particleProfiles = BuildParticleProfiles(_library); + foreach (var particle in _particleProfiles.Values) + { + _adapter.RegisterParticleProfile(particle); + } + + _wallParticle = _particleProfiles.GetValueOrDefault("wall"); + ParticleSize = particleSize; + RefreshSettingsState(); + } + + public string BackendName => "chunk"; + public SimulationSettings Settings => _settings; + public AppSimulationFrameStats FrameStats => _frameStats; + public int Frame => _frame; + public int ParticleCount => _adapter.ParticleCount; + public int ParticleSize { get; } + + public ReadOnlySpan BuildRgbaFrame() + { + var frame = _adapter.BuildRgbaFrame(_settings.EnableWindVisuals, _settings.EnablePressureVisuals); + var stats = _adapter.LastStepStats; + _frameStats.VisualDirtyPageCount = stats.VisualDirtyPages; + _frameStats.FrameBuildBytesTouched = stats.FrameBuildBytesTouched; + _frameStats.RenderTimeMicroseconds = stats.RenderTimeMicroseconds; + return frame; + } + + public void Step(float dt) + { + if (_settings.PauseSim) + { + return; + } + + _adapter.World.ClearDirtyChunks(); + var processed = _adapter.Step(); + _frame++; + _trimCounter++; + if (_trimCounter >= 30) + { + _adapter.TrimResidency(marginChunks: 1); + _trimCounter = 0; + } + + _frameStats.Frame = _frame; + _frameStats.ProcessedCells = Math.Max(processed, _adapter.ParticleCount); + _frameStats.ParticleCount = _adapter.ParticleCount; + _frameStats.LoadedChunkCount = _adapter.World.LoadedChunkCount; + _frameStats.ActiveChunkCount = _adapter.World.ActiveChunkCount; + _frameStats.DirtyChunkCount = _adapter.World.DirtyChunkCount; + _frameStats.SteppedChunkCount = _adapter.LastStepStats.SteppedChunks; + _frameStats.SleepingChunkCount = _adapter.LastStepStats.SleepingChunks; + _frameStats.FieldPageCount = _adapter.LastStepStats.FieldPages; + _frameStats.MoveAttemptCount = _adapter.LastStepStats.MoveAttempts; + _frameStats.VerticalMoveAttemptCount = _adapter.LastStepStats.VerticalMoveAttempts; + _frameStats.DiagonalMoveAttemptCount = _adapter.LastStepStats.DiagonalMoveAttempts; + _frameStats.LateralMoveAttemptCount = _adapter.LastStepStats.LateralMoveAttempts; + _frameStats.SuccessfulMoveCount = _adapter.LastStepStats.SuccessfulMoves; + _frameStats.SwapAttemptCount = _adapter.LastStepStats.SwapAttempts; + _frameStats.StalledMovableCount = _adapter.LastStepStats.StalledMovableCells; + _frameStats.MovementOnlyFastPathCount = _adapter.LastStepStats.MovementOnlyFastPathCount; + _frameStats.FullRuntimeStepCount = _adapter.LastStepStats.FullRuntimeStepCount; + _frameStats.FullRuntimeSolidCount = _adapter.LastStepStats.FullRuntimeSolidCount; + _frameStats.FullRuntimeLiquidCount = _adapter.LastStepStats.FullRuntimeLiquidCount; + _frameStats.FullRuntimeGasCount = _adapter.LastStepStats.FullRuntimeGasCount; + _frameStats.MovedParticleCount = _adapter.LastStepStats.MovedParticles; + _frameStats.SwappedParticleCount = _adapter.LastStepStats.SwappedParticles; + _frameStats.VisualDirtyPageCount = _adapter.LastStepStats.VisualDirtyPages; + _frameStats.ActivationTimeMicroseconds = _adapter.LastStepStats.ActivationTimeMicroseconds; + _frameStats.MovementTimeMicroseconds = _adapter.LastStepStats.MovementTimeMicroseconds; + _frameStats.RuntimeTimeMicroseconds = _adapter.LastStepStats.RuntimeTimeMicroseconds; + _frameStats.FieldDecayTimeMicroseconds = _adapter.LastStepStats.FieldDecayTimeMicroseconds; + _frameStats.RenderTimeMicroseconds = _adapter.LastStepStats.RenderTimeMicroseconds; + UpdateBoundsStats(); + } + + public void Clear() + { + foreach (var (x, y) in _adapter.Particles.ToArray()) + { + _adapter.RemoveParticle(x, y); + } + + _adapter.ClearFields(); + _frame = 0; + _trimCounter = 0; + _frameStats.Frame = 0; + _frameStats.ProcessedCells = 0; + _frameStats.ParticleCount = 0; + _frameStats.MinActiveX = 0; + _frameStats.MinActiveY = 0; + _frameStats.MaxActiveX = 0; + _frameStats.MaxActiveY = 0; + _frameStats.LoadedChunkCount = 0; + _frameStats.ActiveChunkCount = 0; + _frameStats.DirtyChunkCount = 0; + _frameStats.SteppedChunkCount = 0; + _frameStats.SleepingChunkCount = 0; + _frameStats.FieldPageCount = 0; + _frameStats.MoveAttemptCount = 0; + _frameStats.VerticalMoveAttemptCount = 0; + _frameStats.DiagonalMoveAttemptCount = 0; + _frameStats.LateralMoveAttemptCount = 0; + _frameStats.SuccessfulMoveCount = 0; + _frameStats.SwapAttemptCount = 0; + _frameStats.StalledMovableCount = 0; + _frameStats.MovementOnlyFastPathCount = 0; + _frameStats.FullRuntimeStepCount = 0; + _frameStats.FullRuntimeSolidCount = 0; + _frameStats.FullRuntimeLiquidCount = 0; + _frameStats.FullRuntimeGasCount = 0; + _frameStats.MovedParticleCount = 0; + _frameStats.SwappedParticleCount = 0; + _frameStats.VisualDirtyPageCount = 0; + _frameStats.FrameBuildBytesTouched = 0; + _frameStats.ActivationTimeMicroseconds = 0; + _frameStats.MovementTimeMicroseconds = 0; + _frameStats.RuntimeTimeMicroseconds = 0; + _frameStats.FieldDecayTimeMicroseconds = 0; + _frameStats.RenderTimeMicroseconds = 0; + RefreshSettingsState(); + } + + public void RefreshSettingsState() + { + if (_settings.OuterWall) + { + for (var x = 0; x < _adapter.Width; x++) + { + _adapter.AddParticle(x, 0, _wallParticle); + _adapter.AddParticle(x, _adapter.Height - 1, _wallParticle); + } + + for (var y = 0; y < _adapter.Height; y++) + { + _adapter.AddParticle(0, y, _wallParticle); + _adapter.AddParticle(_adapter.Width - 1, y, _wallParticle); + } + } + else + { + for (var x = 0; x < _adapter.Width; x++) + { + RemoveBoundaryWallIfPresent(x, 0); + RemoveBoundaryWallIfPresent(x, _adapter.Height - 1); + } + + for (var y = 0; y < _adapter.Height; y++) + { + RemoveBoundaryWallIfPresent(0, y); + RemoveBoundaryWallIfPresent(_adapter.Width - 1, y); + } + } + } + + public void ClearParticleCircle(int centerX, int centerY, int brushRadius) + { + PaintCircle(centerX, centerY, brushRadius, particleId: null); + } + + public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed) + { + PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId: null); + } + + public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId) + { + PaintCircle(centerX, centerY, brushRadius, particleId); + } + + public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) + { + PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId); + } + + private void PaintCirclePour(int centerX, int centerY, int brushRadius, int maxParticles, int seed, string? particleId) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + var diameter = (brushRadius * 2) + 1; + var touched = 0; + for (var i = 0; i < Math.Max(maxParticles * 6, diameter * diameter) && touched < maxParticles; i++) + { + var dx = ((seed + (i * 17)) % diameter) - brushRadius; + var dy = ((seed + (i * 31)) % diameter) - brushRadius; + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + if (particleId is null) + { + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y)) && _adapter.RemoveParticle(x, y)) + { + touched++; + } + } + else if (TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId)) + { + touched++; + } + } + } + + public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) + { + _adapter.ApplyWindBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY); + } + + public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) + { + _adapter.ApplyAirBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY); + } + + public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) + { + _adapter.ApplyGravityBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength); + } + + public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) + { + _adapter.ApplyRepulsorBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength); + } + + private void PaintCircle(int centerX, int centerY, int brushRadius, string? particleId) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + for (var dx = -brushRadius; dx <= brushRadius; dx++) + { + for (var dy = -brushRadius; dy <= brushRadius; dy++) + { + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + if (particleId is null) + { + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y))) + { + _adapter.RemoveParticle(x, y); + } + + continue; + } + + TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId); + } + } + } + + private bool TryPaintAtCell(int x, int y, string particleId) + { + if (!TryNormalizeCoordinate(ref x, ref y)) + { + return false; + } + + if (!_particleProfiles.TryGetValue(particleId, out var particle) || particle.IsEmpty) + { + return false; + } + + if (_settings.OuterWall && particle.MotionType != PrototypeParticleType.Wall && IsBoundary(x, y)) + { + return false; + } + + return _adapter.AddParticle(x, y, particle); + } + + private void RemoveBoundaryWallIfPresent(int x, int y) + { + if (_adapter.GetParticleTypeAt(x, y) == PrototypeParticleType.Wall) + { + _adapter.RemoveParticle(x, y); + } + } + + private void UpdateBoundsStats() + { + if (_adapter.ParticleCount == 0) + { + _frameStats.MinActiveX = 0; + _frameStats.MinActiveY = 0; + _frameStats.MaxActiveX = 0; + _frameStats.MaxActiveY = 0; + return; + } + + var minX = int.MaxValue; + var minY = int.MaxValue; + var maxX = int.MinValue; + var maxY = int.MinValue; + foreach (var ((x, y), _) in _adapter.ParticleEntries) + { + minX = Math.Min(minX, x); + minY = Math.Min(minY, y); + maxX = Math.Max(maxX, x); + maxY = Math.Max(maxY, y); + } + + _frameStats.MinActiveX = minX; + _frameStats.MinActiveY = minY; + _frameStats.MaxActiveX = maxX; + _frameStats.MaxActiveY = maxY; + } + + private bool TryNormalizeCoordinate(ref int x, ref int y) + { + if (_settings.WrapParticles) + { + x = ((x % _adapter.Width) + _adapter.Width) % _adapter.Width; + y = ((y % _adapter.Height) + _adapter.Height) % _adapter.Height; + return true; + } + + return x >= 0 && x < _adapter.Width && y >= 0 && y < _adapter.Height; + } + + private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == _adapter.Width - 1 || y == _adapter.Height - 1; + + private static Dictionary BuildParticleProfiles(ParticleLibrary library) + { + var idLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++) + { + var definition = library.GetDefinition(typeId); + idLookup[definition.Id] = typeId; + } + + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++) + { + var definition = library.GetDefinition(typeId); + result[definition.Id] = CreateParticleProfile(typeId, definition, idLookup); + } + + return result; + } + + private static PrototypeParticle CreateParticleProfile(ushort typeId, ParticleDef definition, IReadOnlyDictionary idLookup) + { + var motionType = ResolveMotionType(definition); + if (motionType == PrototypeParticleType.Empty) + { + return default; + } + + var runtime = ParticleRuntimeProfileBuilder.Build(definition); + var velocity = definition.Velocity > 0f + ? definition.Velocity + : definition.Kind switch + { + ParticleKind.Solid => 0.45f, + ParticleKind.Liquid => 0.35f, + ParticleKind.Gas => 0.25f, + _ => 0.35f, + }; + + var friction = definition.Friction; + var viscosity = definition.Viscosity; + switch (motionType) + { + case PrototypeParticleType.Sand: + velocity = MathF.Max(0.5f, velocity); + friction = Math.Clamp(friction * 0.75f, 0f, 1f); + viscosity = Math.Clamp(viscosity * 0.75f, 0f, 1.25f); + break; + case PrototypeParticleType.Water: + velocity = MathF.Max(0.4f, velocity); + friction = Math.Clamp(friction * 0.6f, 0f, 1f); + viscosity = Math.Clamp(viscosity * 0.65f, 0f, 1.25f); + break; + case PrototypeParticleType.Steam: + velocity = MathF.Max(0.45f, velocity + (runtime.Balance.UpwardBias * 0.35f)); + friction = Math.Clamp(friction * 0.4f, 0f, 0.75f); + viscosity = Math.Clamp(viscosity * 0.5f, 0f, 0.85f); + break; + } + + var flags = PrototypeParticleFlags.None; + if (definition.Id is "water" or "wet_sand" or "mud") + { + flags |= PrototypeParticleFlags.WaterLike; + } + + if (definition.Id is "fire" or "plasma" or "ember" or "energy") + { + flags |= PrototypeParticleFlags.FireLike | PrototypeParticleFlags.HotSource; + } + + if (definition.Id == "lava" || definition.Id == "burning_wood" || definition.Id.StartsWith("molten_", StringComparison.Ordinal)) + { + flags |= PrototypeParticleFlags.HotSource; + } + + if (definition.Id == "acid") + { + flags |= PrototypeParticleFlags.Acidic; + } + + var hydrateTargetTypeId = definition.Id switch + { + "sand" when idLookup.TryGetValue("wet_sand", out var wetSand) => wetSand, + "dirt" when idLookup.TryGetValue("mud", out var mud) => mud, + _ => (ushort)0, + }; + + var pressureResponse = MathF.Max(0.15f, runtime.Balance.PressureSensitivity); + + return new PrototypeParticle( + TypeId: typeId, + Id: definition.Id, + MotionType: motionType, + Kind: definition.Kind, + BehaviorKind: runtime.Balance.BehaviorKind, + R: definition.Color.R, + G: definition.Color.G, + B: definition.Color.B, + Mass: MathF.Max(0.05f, definition.Mass), + Velocity: Math.Clamp(velocity, 0.05f, 1.4f), + Friction: Math.Clamp(friction, 0f, 1.5f), + Viscosity: Math.Clamp(viscosity, 0f, 2f), + IsStatic: definition.IsStatic || motionType == PrototypeParticleType.Wall, + Flags: flags, + IsMolten: definition.Id == "lava" || definition.Id.StartsWith("molten_", StringComparison.Ordinal), + HydrateTargetTypeId: hydrateTargetTypeId, + MeltTypeId: ResolveOptionalTarget(idLookup, definition.Melt), + EvaporateTypeId: ResolveOptionalTarget(idLookup, definition.Evaporate), + SolidifyTypeId: ResolveOptionalTarget(idLookup, definition.Solidify), + FreezeTypeId: ResolveOptionalTarget(idLookup, definition.Freeze), + BrokenTypeId: ResolveOptionalTarget(idLookup, definition.Broken), + PressureThreshold: definition.PressureThreshold, + PressureResistance: definition.PressureResistance, + PressureTolerance: definition.PressureTolerance, + PressureThresholdDuration: checked((short)Math.Clamp(definition.PressureThresholdDuration, 0, short.MaxValue)), + PressureResponse: pressureResponse, + ForceResponseMultiplier: MathF.Max(0.1f, runtime.Balance.ForceResponseMultiplier), + LateralFlowMultiplier: MathF.Max(0.05f, runtime.Balance.LateralFlowMultiplier), + DiagonalFlowMultiplier: MathF.Max(0.05f, runtime.Balance.DiagonalFlowMultiplier), + UpwardBias: runtime.Balance.UpwardBias, + SideDriftBias: runtime.Balance.SideDriftBias, + InitialTemperature: definition.Temperature, + MeltTemperature: definition.MeltTemperature ?? float.PositiveInfinity, + EvaporateTemperature: definition.EvaporateTemperature ?? float.PositiveInfinity, + SolidifyTemperature: definition.SolidifyTemperature ?? float.NegativeInfinity, + FreezeTemperature: definition.FreezeTemperature ?? float.NegativeInfinity, + BurnDuration: definition.BurnDuration, + BurnTemperature: definition.BurnTemperature, + BurnRate: MathF.Max(0.05f, definition.BurnRate), + BurningInit: definition.Burning, + DefaultLifetime: ResolveDefaultLifetime(definition, runtime.Balance), + HeatEmission: definition.HeatEmission * MathF.Max(0.01f, runtime.Balance.HeatEmissionMultiplier), + SmokeSpawnChance: runtime.Balance.SmokeSpawnChance, + EmberSpawnChance: runtime.Balance.EmberSpawnChance, + Hardness: definition.Hardness, + Durability: definition.Durability, + Flamability: definition.Flamability, + Conductivity: definition.Conductivity, + Conductive: definition.Conductive, + AmbientCoolingMultiplier: runtime.Balance.AmbientCoolingMultiplier, + NeighborHeatTransferMultiplier: runtime.Balance.NeighborHeatTransferMultiplier, + PhaseTransitionHysteresis: runtime.Balance.PhaseTransitionHysteresis, + ProduceTypeId: ResolveOptionalTarget(idLookup, definition.Produces), + ProducesOnDeathTypeId: ResolveOptionalTarget(idLookup, definition.ProducesOnDeath)); + } + + private static ushort ResolveOptionalTarget(IReadOnlyDictionary idLookup, string? particleId) + { + if (string.IsNullOrWhiteSpace(particleId)) + { + return 0; + } + + return idLookup.TryGetValue(particleId, out var typeId) ? typeId : (ushort)0; + } + + private static float ResolveDefaultLifetime(ParticleDef definition, ParticleBalanceProfile balance) + { + var lifetime = (definition.Lifetime ?? 0f) * MathF.Max(0.01f, balance.LifetimeMultiplier); + if (lifetime <= 0f && balance.MaxLifetimeTicks > 0f) + { + lifetime = (balance.MinLifetimeTicks + balance.MaxLifetimeTicks) * 0.5f; + } + + if (balance.MinLifetimeTicks > 0f) + { + lifetime = MathF.Max(lifetime, balance.MinLifetimeTicks); + } + + if (balance.MaxLifetimeTicks > 0f) + { + lifetime = MathF.Min(lifetime, balance.MaxLifetimeTicks); + } + + return lifetime; + } + + private static PrototypeParticleType ResolveMotionType(ParticleDef definition) + { + if (definition.Id is "air" or "wind" or "gravity_well" or "repulsor") + { + return PrototypeParticleType.Empty; + } + + if (definition.Id == "wall" || definition.IsStatic) + { + return PrototypeParticleType.Wall; + } + + if (definition.Id is "fire" or "plasma" or "smoke" or "steam" or "spark" or "energy") + { + return PrototypeParticleType.Steam; + } + + if (definition.Id is "burning_wood") + { + return PrototypeParticleType.Wall; + } + + if (definition.Id is "ember" or "snow") + { + return PrototypeParticleType.Sand; + } + + return definition.Kind switch + { + ParticleKind.Gas => PrototypeParticleType.Steam, + ParticleKind.Liquid => PrototypeParticleType.Water, + ParticleKind.Solid => PrototypeParticleType.Sand, + _ => PrototypeParticleType.Empty, + }; + } +} diff --git a/Sand.App/CoreSimulationBackend.cs b/Sand.App/CoreSimulationBackend.cs new file mode 100644 index 0000000..2e4a61d --- /dev/null +++ b/Sand.App/CoreSimulationBackend.cs @@ -0,0 +1,73 @@ +using Sand.Core; + +namespace Sand.App; + +internal sealed class CoreSimulationBackend : ISimulationBackend +{ + private readonly SandSimulation _simulation; + private readonly AppSimulationFrameStats _frameStats = new(); + + public CoreSimulationBackend(SandSimulation simulation) + { + _simulation = simulation; + } + + public string BackendName => "dense"; + public SimulationSettings Settings => _simulation.Settings; + public AppSimulationFrameStats FrameStats + { + get + { + var source = _simulation.FrameStats; + _frameStats.Frame = source.Frame; + _frameStats.ProcessedCells = source.ProcessedCells; + _frameStats.ParticleCount = source.ParticleCount; + _frameStats.MinActiveX = source.MinActiveX; + _frameStats.MinActiveY = source.MinActiveY; + _frameStats.MaxActiveX = source.MaxActiveX; + _frameStats.MaxActiveY = source.MaxActiveY; + _frameStats.LoadedChunkCount = source.LoadedChunkCount; + _frameStats.ActiveChunkCount = source.ActiveChunkCount; + _frameStats.DirtyChunkCount = source.DirtyChunkCount; + _frameStats.SteppedChunkCount = source.SteppedChunkCount; + _frameStats.SleepingChunkCount = source.SleepingChunkCount; + _frameStats.FieldPageCount = source.FieldPageCount; + _frameStats.MoveAttemptCount = source.MoveAttemptCount; + _frameStats.VerticalMoveAttemptCount = source.VerticalMoveAttemptCount; + _frameStats.DiagonalMoveAttemptCount = source.DiagonalMoveAttemptCount; + _frameStats.LateralMoveAttemptCount = source.LateralMoveAttemptCount; + _frameStats.SuccessfulMoveCount = source.SuccessfulMoveCount; + _frameStats.SwapAttemptCount = source.SwapAttemptCount; + _frameStats.StalledMovableCount = source.StalledMovableCount; + _frameStats.MovementOnlyFastPathCount = source.MovementOnlyFastPathCount; + _frameStats.FullRuntimeStepCount = source.FullRuntimeStepCount; + _frameStats.FullRuntimeSolidCount = source.FullRuntimeSolidCount; + _frameStats.FullRuntimeLiquidCount = source.FullRuntimeLiquidCount; + _frameStats.FullRuntimeGasCount = source.FullRuntimeGasCount; + _frameStats.MovedParticleCount = source.MovedParticleCount; + _frameStats.SwappedParticleCount = source.SwappedParticleCount; + _frameStats.VisualDirtyPageCount = source.VisualDirtyPageCount; + _frameStats.FrameBuildBytesTouched = source.FrameBuildBytesTouched; + _frameStats.ActivationTimeMicroseconds = source.ActivationTimeMicroseconds; + _frameStats.MovementTimeMicroseconds = source.MovementTimeMicroseconds; + _frameStats.RuntimeTimeMicroseconds = source.RuntimeTimeMicroseconds; + _frameStats.FieldDecayTimeMicroseconds = source.FieldDecayTimeMicroseconds; + _frameStats.RenderTimeMicroseconds = source.RenderTimeMicroseconds; + return _frameStats; + } + } + public int Frame => _simulation.Frame; + public int ParticleCount => _simulation.ParticleCount; + public ReadOnlySpan BuildRgbaFrame() => _simulation.BuildRgbaFrame(); + public void Step(float dt) => _simulation.Step(dt); + public void Clear() => _simulation.Clear(); + public void RefreshSettingsState() => _simulation.RefreshSettingsState(); + public void ClearParticleCircle(int centerX, int centerY, int brushRadius) => _simulation.ClearParticleCircle(centerX, centerY, brushRadius); + public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed) => _simulation.ClearParticlePourAtPixel(centerX, centerY, brushRadius, maxParticles, seed); + public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId) => _simulation.CreateParticleCircle(centerX, centerY, brushRadius, particleId); + public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) => _simulation.CreateParticlePourAtPixel(centerX, centerY, brushRadius, particleId, maxParticles, seed); + public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) => _simulation.ApplyWindBrushAtPixel(centerX, centerY, brushRadius, forceX, forceY); + public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) => _simulation.ApplyAirBrushAtPixel(centerX, centerY, brushRadius, forceX, forceY); + public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) => _simulation.ApplyGravityBrushAtPixel(centerX, centerY, brushRadius, strength); + public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) => _simulation.ApplyRepulsorBrushAtPixel(centerX, centerY, brushRadius, strength); +} diff --git a/Sand.App/ISimulationBackend.cs b/Sand.App/ISimulationBackend.cs new file mode 100644 index 0000000..887cbf4 --- /dev/null +++ b/Sand.App/ISimulationBackend.cs @@ -0,0 +1,24 @@ +using Sand.Core; + +namespace Sand.App; + +internal interface ISimulationBackend +{ + string BackendName { get; } + SimulationSettings Settings { get; } + AppSimulationFrameStats FrameStats { get; } + int Frame { get; } + int ParticleCount { get; } + ReadOnlySpan BuildRgbaFrame(); + void Step(float dt); + void Clear(); + void RefreshSettingsState(); + void ClearParticleCircle(int centerX, int centerY, int brushRadius); + void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed); + void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId); + void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed); + void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY); + void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY); + void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength); + void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength); +} diff --git a/Sand.App/Program.cs b/Sand.App/Program.cs new file mode 100644 index 0000000..7fd8659 --- /dev/null +++ b/Sand.App/Program.cs @@ -0,0 +1,9 @@ +namespace Sand.App; + +internal static class Program +{ + public static void Main(string[] args) + { + SandApp.Run(); + } +} diff --git a/Sand.App/Sand.App.csproj b/Sand.App/Sand.App.csproj new file mode 100644 index 0000000..748e3d4 --- /dev/null +++ b/Sand.App/Sand.App.csproj @@ -0,0 +1,22 @@ + + + + + + + + + + + + Exe + net10.0 + enable + enable + true + + + + + + diff --git a/Sand.App/SandApp.Models.cs b/Sand.App/SandApp.Models.cs new file mode 100644 index 0000000..10a02f5 --- /dev/null +++ b/Sand.App/SandApp.Models.cs @@ -0,0 +1,48 @@ +using Raylib_cs; +using Sand.Core; +using System.Numerics; + +namespace Sand.App; + +internal sealed class AppState +{ + public required IParticleLibrary Library { get; init; } + public required SimulationSettings Settings { get; init; } + public required ISimulationBackend Simulation { get; init; } + public required Dictionary> Categories { get; init; } + public required SettingItem[] SettingItems { get; init; } + public required byte[] UploadBuffer { get; init; } + public required string CurrentCategory { get; set; } + public required string CurrentParticle { get; set; } + public required string BackendName { get; init; } + public required int SimWidth { get; init; } + public required int SimHeight { get; init; } + public Texture2D FrameTexture { get; set; } + public int BrushRadius { get; set; } + public bool ZoomEnabled { get; set; } + public bool SettingsVisible { get; set; } + public int CategoryScrollOffset { get; set; } + public int ParticleScrollOffset { get; set; } + public int SettingsScrollOffset { get; set; } + public float SimulationAccumulator { get; set; } + public Vector2 PreviousMouse { get; set; } + public Vector2 LastWindDirection { get; set; } + public PendingWorldAction PendingWorldAction { get; set; } + public int LastSimulationStepCount { get; set; } + public long LastAppFrameTimeMicroseconds { get; set; } + public long LastUpdateTimeMicroseconds { get; set; } + public long LastSimulationLoopTimeMicroseconds { get; set; } + public long LastFrameBuildCallTimeMicroseconds { get; set; } + public long LastTextureUploadTimeMicroseconds { get; set; } + public long LastDrawTimeMicroseconds { get; set; } + public long LastAppOtherTimeMicroseconds { get; set; } +} + +internal readonly record struct SettingItem(string Label, Func Get, Action Toggle); + +internal readonly record struct PendingWorldAction( + bool Active, + bool IsErase, + int SimX, + int SimY, + Vector2 Mouse); diff --git a/Sand.App/SandApp.Rendering.cs b/Sand.App/SandApp.Rendering.cs new file mode 100644 index 0000000..bbd2aab --- /dev/null +++ b/Sand.App/SandApp.Rendering.cs @@ -0,0 +1,252 @@ +using Raylib_cs; +using Sand.Core; +using System.Numerics; +using System.Diagnostics; + +namespace Sand.App; + +internal static partial class SandApp +{ + private static void Draw(AppState state) + { + var buildStart = Stopwatch.GetTimestamp(); + var rgbaFrame = state.Simulation.BuildRgbaFrame(); + state.LastFrameBuildCallTimeMicroseconds = ToMicroseconds(buildStart, Stopwatch.GetTimestamp()); + + var uploadStart = Stopwatch.GetTimestamp(); + unsafe + { + fixed (byte* pixels = rgbaFrame) + { + Raylib.UpdateTexture(state.FrameTexture, pixels); + } + } + state.LastTextureUploadTimeMicroseconds = ToMicroseconds(uploadStart, Stopwatch.GetTimestamp()); + + var mouse = Raylib.GetMousePosition(); + var worldRect = new Rectangle(SidebarWidth, 0, state.SimWidth * ParticleSize, state.SimHeight * ParticleSize); + var settingsRect = GetSettingsRect(state); + + var drawStart = Stopwatch.GetTimestamp(); + Raylib.BeginDrawing(); + Raylib.ClearBackground(new Color(18, 18, 24, 255)); + DrawSidebar(state); + Raylib.DrawTextureEx(state.FrameTexture, new Vector2(SidebarWidth, 0), 0f, ParticleSize, Color.White); + + if (state.Settings.EnableCursor) + { + DrawBrushCursor(mouse, state.BrushRadius); + } + + if (state.ZoomEnabled && Raylib.CheckCollisionPointRec(mouse, worldRect)) + { + DrawZoom(state.FrameTexture, mouse, state.SimWidth, state.SimHeight); + } + + if (state.SettingsVisible) + { + DrawSettingsPanel(state, settingsRect, state.SettingItems); + } + + DrawStatusBar(state); + + if (state.Settings.EnableDebug || state.Settings.EnableFps) + { + DrawDebug(state, Raylib.GetFrameTime()); + } + + Raylib.EndDrawing(); + state.LastDrawTimeMicroseconds = ToMicroseconds(drawStart, Stopwatch.GetTimestamp()); + } + + private static void DrawSidebar(AppState state) + { + Raylib.DrawRectangle(0, 0, SidebarWidth, Raylib.GetScreenHeight(), new Color(28, 32, 40, 255)); + Raylib.DrawText($"Sand C# ({state.BackendName})", 12, 16, 24, new Color(100, 170, 255, 255)); + + var y = 60; + var categoryTop = 60; + var categoryHeight = 180; + var visibleCategoryRows = GetVisibleCategoryRows(); + Raylib.BeginScissorMode(12, categoryTop, 196, categoryHeight); + foreach (var category in state.Categories.Keys.Skip(state.CategoryScrollOffset).Take(visibleCategoryRows)) + { + var selected = category == state.CurrentCategory; + Raylib.DrawRectangleRounded(new Rectangle(12, y, 196, 32), 0.2f, 6, selected ? new Color(66, 128, 182, 255) : new Color(48, 54, 66, 255)); + Raylib.DrawText(category, 22, y + 8, 18, Color.White); + y += 38; + } + Raylib.EndScissorMode(); + + var categoryCount = state.Categories.Count; + if (categoryCount > visibleCategoryRows) + { + var trackRect = new Rectangle(202, categoryTop, 6, categoryHeight); + var thumbHeight = MathF.Max(24f, categoryHeight * (visibleCategoryRows / (float)categoryCount)); + var maxOffset = categoryCount - visibleCategoryRows; + var thumbTravel = MathF.Max(0f, categoryHeight - thumbHeight); + var thumbY = categoryTop + ((state.CategoryScrollOffset / (float)maxOffset) * thumbTravel); + Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255)); + Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255)); + } + + y = 240; + var listTop = 240; + var listHeight = Math.Max(28, Raylib.GetScreenHeight() - 404); + var visibleRows = GetVisibleParticleRows(); + Raylib.BeginScissorMode(12, listTop, 196, listHeight); + foreach (var particle in state.Categories[state.CurrentCategory].Skip(state.ParticleScrollOffset).Take(visibleRows)) + { + var selected = particle.Id == state.CurrentParticle; + var bg = selected ? new Color(230, 230, 240, 255) : new Color(particle.Color.R, particle.Color.G, particle.Color.B, (byte)255); + Raylib.DrawRectangle(12, y, 196, 24, bg); + Raylib.DrawText(particle.Name, 18, y + 4, 16, Color.Black); + y += 28; + } + Raylib.EndScissorMode(); + + var particleCount = state.Categories[state.CurrentCategory].Count; + if (particleCount > visibleRows) + { + var trackRect = new Rectangle(202, listTop, 6, listHeight); + var thumbHeight = MathF.Max(24f, listHeight * (visibleRows / (float)particleCount)); + var maxOffset = particleCount - visibleRows; + var thumbTravel = MathF.Max(0f, listHeight - thumbHeight); + var thumbY = listTop + ((state.ParticleScrollOffset / (float)maxOffset) * thumbTravel); + Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255)); + Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255)); + } + + Raylib.DrawText($"Brush {state.BrushRadius}", 12, 204, 18, Color.White); + Raylib.DrawText(state.Settings.PauseSim ? "Paused" : "Running", 120, 204, 18, state.Settings.PauseSim ? Color.Yellow : Color.Green); + Raylib.DrawText($"Particles {state.Simulation.ParticleCount}", 12, Raylib.GetScreenHeight() - 126, 18, Color.LightGray); + + Raylib.DrawRectangleRounded(new Rectangle(12, Raylib.GetScreenHeight() - 96, 196, 32), 0.2f, 6, new Color(160, 58, 58, 255)); + Raylib.DrawText("Clear Grid", 62, Raylib.GetScreenHeight() - 88, 18, Color.White); + Raylib.DrawRectangleRounded(new Rectangle(12, Raylib.GetScreenHeight() - 54, 196, 32), 0.2f, 6, state.SettingsVisible ? new Color(98, 112, 136, 255) : new Color(72, 80, 90, 255)); + Raylib.DrawText("Settings", 68, Raylib.GetScreenHeight() - 46, 18, Color.White); + } + + private static void DrawSettingsPanel(AppState state, Rectangle panelRect, IReadOnlyList items) + { + Raylib.DrawRectangleRounded(panelRect, 0.08f, 8, new Color(18, 22, 28, 240)); + Raylib.DrawRectangleLinesEx(panelRect, 1.5f, new Color(110, 140, 170, 255)); + Raylib.DrawText("Engine Settings", (int)panelRect.X + 14, (int)panelRect.Y + 10, 22, Color.White); + + var y = panelRect.Y + 18; + var listTop = panelRect.Y + 18; + var listHeight = Math.Max(28f, panelRect.Height - 68f); + var visibleRows = GetVisibleSettingsRows(panelRect); + Raylib.BeginScissorMode((int)panelRect.X + 12, (int)listTop, (int)panelRect.Width - 24, (int)listHeight); + foreach (var item in items.Skip(state.SettingsScrollOffset).Take(visibleRows)) + { + var rowRect = new Rectangle(panelRect.X + 12, y, panelRect.Width - 24, 26); + Raylib.DrawRectangleRounded(rowRect, 0.15f, 6, item.Get() ? new Color(63, 117, 87, 255) : new Color(63, 69, 78, 255)); + Raylib.DrawText(item.Label, (int)rowRect.X + 10, (int)rowRect.Y + 5, 18, Color.White); + Raylib.DrawText(item.Get() ? "On" : "Off", (int)rowRect.X + (int)rowRect.Width - 38, (int)rowRect.Y + 5, 18, Color.White); + y += 34; + } + Raylib.EndScissorMode(); + + if (items.Count > visibleRows) + { + var trackRect = new Rectangle(panelRect.X + panelRect.Width - 10, listTop, 4, listHeight); + var thumbHeight = MathF.Max(24f, listHeight * (visibleRows / (float)items.Count)); + var maxOffset = items.Count - visibleRows; + var thumbTravel = MathF.Max(0f, listHeight - thumbHeight); + var thumbY = listTop + ((state.SettingsScrollOffset / (float)maxOffset) * thumbTravel); + Raylib.DrawRectangleRounded(trackRect, 0.4f, 4, new Color(52, 58, 72, 255)); + Raylib.DrawRectangleRounded(new Rectangle(trackRect.X, thumbY, trackRect.Width, thumbHeight), 0.4f, 4, new Color(124, 140, 164, 255)); + } + + var speedDownRect = new Rectangle(panelRect.X + 12, panelRect.Y + panelRect.Height - 36, 48, 24); + var speedUpRect = new Rectangle(panelRect.X + 72, panelRect.Y + panelRect.Height - 36, 48, 24); + Raylib.DrawRectangleRounded(speedDownRect, 0.15f, 6, new Color(63, 69, 78, 255)); + Raylib.DrawRectangleRounded(speedUpRect, 0.15f, 6, new Color(63, 69, 78, 255)); + Raylib.DrawText("-", (int)speedDownRect.X + 18, (int)speedDownRect.Y + 4, 20, Color.White); + Raylib.DrawText("+", (int)speedUpRect.X + 17, (int)speedUpRect.Y + 3, 20, Color.White); + } + + private static void DrawStatusBar(AppState state) + { + var y = Raylib.GetScreenHeight() - 28; + Raylib.DrawRectangle(SidebarWidth, y, Raylib.GetScreenWidth() - SidebarWidth, 28, new Color(12, 14, 18, 220)); + var stats = state.Simulation.FrameStats; + var status = $"Current {state.CurrentParticle} | Brush {state.BrushRadius} | Speed {state.Settings.TimeScale:0.0}x @ {state.Settings.SimulationStepsPerSecond:0}Hz | Cells {stats.ProcessedCells} | Bounds {stats.MinActiveX},{stats.MinActiveY} - {stats.MaxActiveX},{stats.MaxActiveY}"; + if (stats.LoadedChunkCount > 0 || stats.ActiveChunkCount > 0) + { + status += $" | Chunks {stats.ActiveChunkCount}/{stats.LoadedChunkCount}"; + status += $" step {stats.SteppedChunkCount} sleep {stats.SleepingChunkCount}"; + } + if (state.Settings.WrapParticles) + { + status += " | Wrap"; + } + if (state.Settings.OuterWall) + { + status += " | OuterWall"; + } + + Raylib.DrawText(status, SidebarWidth + 10, y + 6, 16, Color.LightGray); + } + + private static void DrawBrushCursor(Vector2 mouse, int brushRadius) + { + if (mouse.X < SidebarWidth) + { + return; + } + + Raylib.DrawCircleLines((int)mouse.X, (int)mouse.Y, brushRadius * ParticleSize, new Color(255, 255, 255, 180)); + } + + private static void DrawZoom(Texture2D frameTexture, Vector2 mouse, int simWidth, int simHeight) + { + var simMouseX = Math.Clamp((int)((mouse.X - SidebarWidth) / ParticleSize), 0, simWidth - 1); + var simMouseY = Math.Clamp((int)(mouse.Y / ParticleSize), 0, simHeight - 1); + var srcX = Math.Clamp(simMouseX - 8, 0, simWidth - 16); + var srcY = Math.Clamp(simMouseY - 8, 0, simHeight - 16); + var source = new Rectangle(srcX, srcY, 16, 16); + var target = new Rectangle(Raylib.GetScreenWidth() - 220, 20, 180, 180); + Raylib.DrawRectangleRounded(target, 0.1f, 6, new Color(22, 22, 30, 230)); + Raylib.DrawTexturePro(frameTexture, source, target, Vector2.Zero, 0f, Color.White); + Raylib.DrawRectangleLinesEx(target, 2f, new Color(255, 255, 255, 160)); + } + + private static void DrawDebug(AppState state, float dt) + { + var rect = new Rectangle(236, 12, 430, 256); + Raylib.DrawRectangleRounded(rect, 0.15f, 6, new Color(16, 16, 16, 190)); + var y = 24; + if (state.Settings.EnableFps) + { + Raylib.DrawText($"FPS {Raylib.GetFPS()}", 248, y, 18, Color.White); + y += 20; + } + + if (state.Settings.EnableDebug) + { + var stats = state.Simulation.FrameStats; + Raylib.DrawText($"Frame {state.Simulation.Frame}", 248, y, 18, Color.White); + Raylib.DrawText($"Particle {state.CurrentParticle}", 248, y + 20, 18, Color.White); + Raylib.DrawText($"Brush {state.BrushRadius} dt {dt:0.000}", 248, y + 40, 18, Color.White); + Raylib.DrawText($"Processed {stats.ProcessedCells} particles {stats.ParticleCount}", 248, y + 60, 18, Color.White); + Raylib.DrawText($"Bounds {stats.MinActiveX},{stats.MinActiveY} - {stats.MaxActiveX},{stats.MaxActiveY}", 248, y + 80, 18, Color.White); + if (stats.LoadedChunkCount > 0 || stats.ActiveChunkCount > 0) + { + Raylib.DrawText($"Chunks loaded {stats.LoadedChunkCount} active {stats.ActiveChunkCount} dirty {stats.DirtyChunkCount}", 248, y + 100, 18, Color.White); + Raylib.DrawText($"Moves {stats.MovedParticleCount} swaps {stats.SwappedParticleCount} attempts {stats.MoveAttemptCount} stalled {stats.StalledMovableCount}", 248, y + 120, 18, Color.White); + Raylib.DrawText($"Att v {stats.VerticalMoveAttemptCount} d {stats.DiagonalMoveAttemptCount} l {stats.LateralMoveAttemptCount} swap {stats.SwapAttemptCount}", 248, y + 140, 18, Color.White); + Raylib.DrawText($"Fast {stats.MovementOnlyFastPathCount} full {stats.FullRuntimeStepCount} s {stats.FullRuntimeSolidCount} l {stats.FullRuntimeLiquidCount} g {stats.FullRuntimeGasCount}", 248, y + 160, 18, Color.White); + Raylib.DrawText($"ms act {stats.ActivationTimeMicroseconds / 1000f:0.00} move {stats.MovementTimeMicroseconds / 1000f:0.00} run {stats.RuntimeTimeMicroseconds / 1000f:0.00} render {stats.RenderTimeMicroseconds / 1000f:0.00}", 248, y + 180, 18, Color.White); + Raylib.DrawText($"app frame {state.LastAppFrameTimeMicroseconds / 1000f:0.00} upd {state.LastUpdateTimeMicroseconds / 1000f:0.00} sim {state.LastSimulationLoopTimeMicroseconds / 1000f:0.00} steps {state.LastSimulationStepCount}", 248, y + 200, 18, Color.White); + Raylib.DrawText($"app build {state.LastFrameBuildCallTimeMicroseconds / 1000f:0.00} upload {state.LastTextureUploadTimeMicroseconds / 1000f:0.00} draw {state.LastDrawTimeMicroseconds / 1000f:0.00} other {state.LastAppOtherTimeMicroseconds / 1000f:0.00}", 248, y + 220, 18, Color.White); + } + else + { + Raylib.DrawText($"app frame {state.LastAppFrameTimeMicroseconds / 1000f:0.00} upd {state.LastUpdateTimeMicroseconds / 1000f:0.00} sim {state.LastSimulationLoopTimeMicroseconds / 1000f:0.00} steps {state.LastSimulationStepCount}", 248, y + 100, 18, Color.White); + Raylib.DrawText($"app build {state.LastFrameBuildCallTimeMicroseconds / 1000f:0.00} upload {state.LastTextureUploadTimeMicroseconds / 1000f:0.00} draw {state.LastDrawTimeMicroseconds / 1000f:0.00} other {state.LastAppOtherTimeMicroseconds / 1000f:0.00}", 248, y + 120, 18, Color.White); + } + } + } +} diff --git a/Sand.App/SandApp.Sidebar.cs b/Sand.App/SandApp.Sidebar.cs new file mode 100644 index 0000000..94aaf01 --- /dev/null +++ b/Sand.App/SandApp.Sidebar.cs @@ -0,0 +1,108 @@ +using Raylib_cs; +using System.Numerics; + +namespace Sand.App; + +internal static partial class SandApp +{ + private static void HandleSidebar(AppState state, Vector2 mouse) + { + if (mouse.X >= SidebarWidth) + { + return; + } + + var y = 60; + foreach (var category in state.Categories.Keys.Skip(state.CategoryScrollOffset).Take(GetVisibleCategoryRows())) + { + var rect = new Rectangle(12, y, 196, 32); + if (Raylib.CheckCollisionPointRec(mouse, rect)) + { + state.CurrentCategory = category; + state.ParticleScrollOffset = 0; + if (state.Categories[state.CurrentCategory].Count > 0) + { + state.CurrentParticle = state.Categories[state.CurrentCategory][0].Id; + } + return; + } + + y += 38; + } + + y = 240; + var visibleRows = GetVisibleParticleRows(); + foreach (var particle in state.Categories[state.CurrentCategory].Skip(state.ParticleScrollOffset).Take(visibleRows)) + { + var rect = new Rectangle(12, y, 196, 24); + if (Raylib.CheckCollisionPointRec(mouse, rect)) + { + state.CurrentParticle = particle.Id; + return; + } + + y += 28; + } + + var clearRect = new Rectangle(12, Raylib.GetScreenHeight() - 96, 196, 32); + if (Raylib.CheckCollisionPointRec(mouse, clearRect)) + { + state.Simulation.Clear(); + return; + } + + var settingsRect = new Rectangle(12, Raylib.GetScreenHeight() - 54, 196, 32); + if (Raylib.CheckCollisionPointRec(mouse, settingsRect)) + { + state.SettingsVisible = !state.SettingsVisible; + state.Settings.EnableDebug = state.SettingsVisible || state.Settings.EnableDebug; + } + } + + private static void HandleSettingsPanel(AppState state, Rectangle panelRect, Vector2 mouse) + { + if (!Raylib.CheckCollisionPointRec(mouse, panelRect)) + { + return; + } + + var y = panelRect.Y + 18; + foreach (var item in state.SettingItems.Skip(state.SettingsScrollOffset).Take(GetVisibleSettingsRows(panelRect))) + { + var rowRect = new Rectangle(panelRect.X + 12, y, panelRect.Width - 24, 26); + if (Raylib.CheckCollisionPointRec(mouse, rowRect)) + { + item.Toggle(); + state.Simulation.RefreshSettingsState(); + return; + } + + y += 34; + } + + var speedDownRect = new Rectangle(panelRect.X + 12, panelRect.Y + panelRect.Height - 36, 48, 24); + var speedUpRect = new Rectangle(panelRect.X + 72, panelRect.Y + panelRect.Height - 36, 48, 24); + if (Raylib.CheckCollisionPointRec(mouse, speedDownRect)) + { + state.Simulation.Settings.TimeScale = MathF.Max(0.1f, state.Simulation.Settings.TimeScale - 0.1f); + state.SimulationAccumulator = 0f; + return; + } + + if (Raylib.CheckCollisionPointRec(mouse, speedUpRect)) + { + state.Simulation.Settings.TimeScale = MathF.Min(4f, state.Simulation.Settings.TimeScale + 0.1f); + state.SimulationAccumulator = 0f; + } + } + + private static int GetVisibleParticleRows() + { + return Math.Max(1, (Raylib.GetScreenHeight() - 404) / 28); + } + + private static int GetVisibleCategoryRows() + { + return Math.Max(1, (240 - 60) / 38); + } +} diff --git a/Sand.App/SandApp.Update.cs b/Sand.App/SandApp.Update.cs new file mode 100644 index 0000000..a1c7dd1 --- /dev/null +++ b/Sand.App/SandApp.Update.cs @@ -0,0 +1,332 @@ +using Raylib_cs; +using Sand.Core; +using System.Numerics; +using System.Diagnostics; + +namespace Sand.App; + +internal static partial class SandApp +{ + private static void Update(AppState state) + { + var updateStart = Stopwatch.GetTimestamp(); + var dt = Raylib.GetFrameTime(); + var mouse = Raylib.GetMousePosition(); + var worldRect = new Rectangle(SidebarWidth, 0, state.SimWidth * ParticleSize, state.SimHeight * ParticleSize); + var overSidebar = mouse.X < SidebarWidth; + var settingsRect = GetSettingsRect(state); + + HandleHotkeys(state); + HandleMouseWheel(state, mouse, overSidebar); + HandleWorldPainting(state, mouse, worldRect, overSidebar, settingsRect); + HandleSidebarClick(state, mouse); + StepSimulation(state, dt, mouse); + + state.PreviousMouse = mouse; + state.LastUpdateTimeMicroseconds = ToMicroseconds(updateStart, Stopwatch.GetTimestamp()); + } + + private static void HandleHotkeys(AppState state) + { + if (Raylib.IsKeyPressed(KeyboardKey.Space)) + { + state.Settings.PauseSim = !state.Settings.PauseSim; + } + + if (Raylib.IsKeyPressed(KeyboardKey.C)) + { + state.Simulation.Clear(); + } + + if (Raylib.IsKeyPressed(KeyboardKey.Z)) + { + state.ZoomEnabled = !state.ZoomEnabled; + } + + if (Raylib.IsKeyPressed(KeyboardKey.F3)) + { + state.Settings.EnableDebug = !state.Settings.EnableDebug; + } + + if (Raylib.IsKeyPressed(KeyboardKey.Tab)) + { + state.SettingsVisible = !state.SettingsVisible; + } + } + + private static void HandleMouseWheel(AppState state, Vector2 mouse, bool overSidebar) + { + var wheel = Raylib.GetMouseWheelMove(); + if (MathF.Abs(wheel) <= 0f) + { + return; + } + + var settingsRect = GetSettingsRect(state); + if (state.SettingsVisible && Raylib.CheckCollisionPointRec(mouse, settingsRect)) + { + var maxOffset = Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(settingsRect)); + state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset - (int)wheel, 0, maxOffset); + return; + } + + var overCategoryList = overSidebar && mouse.Y >= 60 && mouse.Y < 240; + if (overCategoryList) + { + var maxOffset = Math.Max(0, state.Categories.Count - GetVisibleCategoryRows()); + state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset - (int)wheel, 0, maxOffset); + return; + } + + var overParticleList = overSidebar && mouse.Y >= 240 && mouse.Y <= Raylib.GetScreenHeight() - 164; + if (overParticleList) + { + var visibleRows = GetVisibleParticleRows(); + var maxOffset = Math.Max(0, state.Categories[state.CurrentCategory].Count - visibleRows); + state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset - (int)wheel, 0, maxOffset); + return; + } + + state.BrushRadius = Math.Clamp(state.BrushRadius + (int)wheel, 1, 16); + } + + private static void HandleWorldPainting(AppState state, Vector2 mouse, Rectangle worldRect, bool overSidebar, Rectangle settingsRect) + { + var canPaintWorld = + !overSidebar && + (!state.SettingsVisible || !Raylib.CheckCollisionPointRec(mouse, settingsRect)) && + Raylib.CheckCollisionPointRec(mouse, worldRect); + + state.PendingWorldAction = default; + + if (Raylib.IsMouseButtonDown(MouseButton.Left) && canPaintWorld) + { + var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize; + var simY = (int)(mouse.Y / ParticleSize) * ParticleSize; + state.PendingWorldAction = new PendingWorldAction(true, false, simX, simY, mouse); + if (Raylib.IsMouseButtonPressed(MouseButton.Left)) + { + ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true); + } + } + + if (Raylib.IsMouseButtonDown(MouseButton.Right) && canPaintWorld) + { + var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize; + var simY = (int)(mouse.Y / ParticleSize) * ParticleSize; + state.PendingWorldAction = new PendingWorldAction(true, true, simX, simY, mouse); + if (Raylib.IsMouseButtonPressed(MouseButton.Right)) + { + ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: false); + } + } + } + + private static void ApplyWorldAction(AppState state, PendingWorldAction action, bool updateWindDirection, bool continuous = false) + { + if (!action.Active) + { + return; + } + + if (action.IsErase) + { + state.Simulation.ClearParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, GetContinuousEraseCount(state), state.Simulation.Frame); + return; + } + + if (updateWindDirection && state.CurrentParticle is "wind" or "air") + { + var drag = action.Mouse - state.PreviousMouse; + if (drag.LengthSquared() > 0.25f) + { + state.LastWindDirection = Vector2.Normalize(drag); + } + } + + switch (state.CurrentParticle) + { + case "wind": + state.Simulation.ApplyWindBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y); + break; + case "air": + state.Simulation.ApplyAirBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y); + break; + case "gravity_well": + state.Simulation.ApplyGravityBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3.5f); + break; + case "repulsor": + state.Simulation.ApplyRepulsorBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3f); + break; + default: + if (continuous) + { + var pourCount = GetContinuousPaintCount(state); + state.Simulation.CreateParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle, pourCount, state.Simulation.Frame); + } + else + { + state.Simulation.CreateParticleCircle(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle); + } + break; + } + } + + private static void HandleSidebarClick(AppState state, Vector2 mouse) + { + if (!Raylib.IsMouseButtonPressed(MouseButton.Left)) + { + return; + } + + HandleSidebar(state, mouse); + if (state.SettingsVisible) + { + HandleSettingsPanel(state, GetSettingsRect(state), mouse); + } + } + + private static void StepSimulation(AppState state, float dt, Vector2 mouse) + { + var simStart = Stopwatch.GetTimestamp(); + state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset, 0, Math.Max(0, state.Categories.Count - GetVisibleCategoryRows())); + state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset, 0, Math.Max(0, state.Categories[state.CurrentCategory].Count - GetVisibleParticleRows())); + state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset, 0, Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(GetSettingsRect(state)))); + state.LastSimulationStepCount = 0; + + if (state.Settings.PauseSim) + { + if (state.PendingWorldAction.Active) + { + ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state)); + } + + state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp()); + return; + } + + state.SimulationAccumulator += dt; + var fixedStep = 1f / MathF.Max(1f, state.Settings.SimulationStepsPerSecond); + var overload = IsInteractiveOverloaded(state, fixedStep); + var maxSteps = overload ? 2 : 4; + while (state.SimulationAccumulator >= fixedStep && maxSteps-- > 0) + { + if (state.PendingWorldAction.Active) + { + ApplyWorldAction(state, state.PendingWorldAction with { Mouse = mouse }, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state)); + } + + state.Simulation.Step(fixedStep * state.Settings.TimeScale); + state.LastSimulationStepCount++; + state.SimulationAccumulator -= fixedStep; + } + + if (state.SimulationAccumulator > fixedStep * (overload ? 1.5f : 3f)) + { + state.SimulationAccumulator = fixedStep; + } + + state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp()); + } + + private static Rectangle GetSettingsRect(AppState state) + { + return new Rectangle( + Raylib.GetScreenWidth() - SettingsWidth - 12, + 100, + SettingsWidth, + MathF.Min(32 + (state.SettingItems.Length * 34), Raylib.GetScreenHeight() - 120)); + } + + private static int GetVisibleSettingsRows(Rectangle panelRect) + { + return Math.Max(1, (int)((panelRect.Height - 68f) / 34f)); + } + + private static bool ShouldUseContinuousPaint(AppState state) + { + if (state.CurrentParticle is "wind" or "air" or "gravity_well" or "repulsor") + { + return true; + } + + var typeId = state.Library.GetTypeId(state.CurrentParticle); + var particle = state.Library.GetDefinition(typeId); + return particle.Kind == ParticleKind.Gas + || particle.Kind == ParticleKind.Liquid + || (particle.Kind == ParticleKind.Solid && !particle.IsStatic); + } + + private static int GetContinuousPaintCount(AppState state) + { + var typeId = state.Library.GetTypeId(state.CurrentParticle); + var particle = state.Library.GetDefinition(typeId); + var baseCount = GetContinuousPaintCount(particle, state.BrushRadius); + return ApplyInteractiveLoadScaling(state, baseCount, particle.Kind == ParticleKind.Gas); + } + + internal static int GetContinuousPaintCount(ParticleDef particle, int brushRadius) + { + return particle.Kind switch + { + ParticleKind.Gas => Math.Clamp(brushRadius + 1, 1, 8), + ParticleKind.Liquid => Math.Clamp((brushRadius * 2) + 1, 1, 24), + ParticleKind.Solid when !particle.IsStatic => Math.Clamp((brushRadius * 2) + 1, 1, 24), + _ => 0, + }; + } + + private static int GetContinuousEraseCount(AppState state) + { + var baseCount = Math.Clamp((state.BrushRadius * 2) + 2, 1, 18); + return ApplyInteractiveLoadScaling(state, baseCount, gasBiased: true); + } + + private static bool IsInteractiveOverloaded(AppState state, float fixedStep) + { + var stepBudgetMicros = fixedStep * 1_000_000f; + if (state.LastSimulationStepCount >= 4) + { + return true; + } + + if (state.LastSimulationLoopTimeMicroseconds > stepBudgetMicros * 2.25f) + { + return true; + } + + return state.LastAppFrameTimeMicroseconds > 40_000; + } + + private static int ApplyInteractiveLoadScaling(AppState state, int baseCount, bool gasBiased) + { + if (baseCount <= 1) + { + return baseCount; + } + + var severeOverload = + state.LastSimulationStepCount >= 4 || + state.LastSimulationLoopTimeMicroseconds > 45_000 || + state.LastAppFrameTimeMicroseconds > 60_000; + if (severeOverload) + { + return gasBiased + ? Math.Max(1, baseCount / 4) + : Math.Max(1, baseCount / 3); + } + + var overload = + state.LastSimulationStepCount >= 2 || + state.LastSimulationLoopTimeMicroseconds > 25_000 || + state.LastAppFrameTimeMicroseconds > 33_000; + if (overload) + { + return gasBiased + ? Math.Max(1, baseCount / 2) + : Math.Max(1, (baseCount * 2) / 3); + } + + return baseCount; + } +} diff --git a/Sand.App/SandApp.cs b/Sand.App/SandApp.cs new file mode 100644 index 0000000..c4ada10 --- /dev/null +++ b/Sand.App/SandApp.cs @@ -0,0 +1,179 @@ +using Raylib_cs; +using Sand.Core; +using System.Numerics; +using System.Diagnostics; + +namespace Sand.App; + +internal static partial class SandApp +{ + private const int ScreenWidth = 1280; + private const int ScreenHeight = 800; + private const int SidebarWidth = 220; + private const int SettingsWidth = 320; + private const int ParticleSize = 4; + + public static void Run() + { + var state = CreateState(); + + Raylib.SetConfigFlags(ConfigFlags.ResizableWindow); + Raylib.InitWindow(ScreenWidth, ScreenHeight, $"Sand - C# Port ({state.BackendName})"); + Raylib.SetTargetFPS(120); + + var frameImage = Raylib.GenImageColor(state.SimWidth, state.SimHeight, Color.Black); + state.FrameTexture = Raylib.LoadTextureFromImage(frameImage); + Raylib.UnloadImage(frameImage); + + while (!Raylib.WindowShouldClose()) + { + var frameStart = Stopwatch.GetTimestamp(); + Update(state); + Draw(state); + var frameEnd = Stopwatch.GetTimestamp(); + state.LastAppFrameTimeMicroseconds = ToMicroseconds(frameStart, frameEnd); + state.LastAppOtherTimeMicroseconds = Math.Max( + 0, + state.LastAppFrameTimeMicroseconds - + state.LastUpdateTimeMicroseconds - + state.LastFrameBuildCallTimeMicroseconds - + state.LastTextureUploadTimeMicroseconds - + state.LastDrawTimeMicroseconds); + } + + Raylib.UnloadTexture(state.FrameTexture); + Raylib.CloseWindow(); + } + + private static AppState CreateState() + { + var contentRoot = Path.Combine(AppContext.BaseDirectory, "Content", "part"); + var library = ParticleLibraryLoader.LoadFromDirectory(contentRoot); + var settings = new SimulationSettings + { + StorageMode = SimulationStorageMode.Dense, + PauseSim = false, + EnableDebug = true, + EnableFps = true, + EnableCursor = true, + EnableGasEffect = true, + }; + + var simWidth = (ScreenWidth - SidebarWidth) / ParticleSize; + var simHeight = ScreenHeight / ParticleSize; + var simulation = CreateSimulationBackend(simWidth, simHeight, library, settings); + var categories = BuildCategories(library, simulation.BackendName); + var currentCategory = "Solids"; + + return new AppState + { + Library = library, + Settings = settings, + Simulation = simulation, + Categories = categories, + CurrentCategory = currentCategory, + CurrentParticle = categories[currentCategory][0].Id, + BackendName = simulation.BackendName, + BrushRadius = 3, + SimWidth = simWidth, + SimHeight = simHeight, + UploadBuffer = new byte[simWidth * simHeight * 4], + LastWindDirection = new Vector2(1f, 0f), + SettingItems = + [ + new SettingItem("Cursor", () => settings.EnableCursor, () => settings.EnableCursor = !settings.EnableCursor), + new SettingItem("Glow", () => settings.EnableGlow, () => settings.EnableGlow = !settings.EnableGlow), + new SettingItem("Gas FX", () => settings.EnableGasEffect, () => settings.EnableGasEffect = !settings.EnableGasEffect), + new SettingItem("Wind FX", () => settings.EnableWindVisuals, () => settings.EnableWindVisuals = !settings.EnableWindVisuals), + new SettingItem("Pressure FX", () => settings.EnablePressureVisuals, () => settings.EnablePressureVisuals = !settings.EnablePressureVisuals), + new SettingItem("Debug", () => settings.EnableDebug, () => settings.EnableDebug = !settings.EnableDebug), + new SettingItem("FPS", () => settings.EnableFps, () => settings.EnableFps = !settings.EnableFps), + new SettingItem("Temp FX", () => settings.EnableTempVisuals, () => settings.EnableTempVisuals = !settings.EnableTempVisuals), + new SettingItem("Wrap", () => settings.WrapParticles, () => settings.WrapParticles = !settings.WrapParticles), + new SettingItem("Outer Wall", () => settings.OuterWall, () => settings.OuterWall = !settings.OuterWall), + ], + }; + } + + private static ISimulationBackend CreateSimulationBackend(int simWidth, int simHeight, IParticleLibrary library, SimulationSettings settings) + { + var backend = Environment.GetEnvironmentVariable("SAND_BACKEND"); + var storageMode = settings.StorageMode; + if (string.Equals(backend, "chunk", StringComparison.OrdinalIgnoreCase)) + { + storageMode = SimulationStorageMode.ChunkPrototype; + } + else if (string.Equals(backend, "dense", StringComparison.OrdinalIgnoreCase)) + { + storageMode = SimulationStorageMode.Dense; + } + + if (storageMode == SimulationStorageMode.ChunkPrototype) + { + return new ChunkPrototypeSimulationBackend(simWidth, simHeight, ParticleSize, library, settings); + } + + return new CoreSimulationBackend(new SandSimulation(simWidth, simHeight, ParticleSize, library, settings)); + } + + private static Dictionary> BuildCategories(IParticleLibrary library, string backendName) + { + if (string.Equals(backendName, "chunk", StringComparison.OrdinalIgnoreCase)) + { + return BuildChunkPrototypeCategories(library); + } + + var special = library.Definitions.Where(static d => d.IsSpecial).OrderBy(static d => d.Name).ToList(); + if (!special.Any(static d => d.Id == "air")) + { + special.Add(new ParticleDef + { + Id = "air", + Name = "Air", + Kind = ParticleKind.Gas, + IsSpecial = true, + Color = new Rgb24(160, 210, 255), + }); + } + + special = special.OrderBy(static d => d.Name).ToList(); + + return new Dictionary> + { + ["Solids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Solid && !d.IsSpecial).OrderBy(static d => d.Name).ToList(), + ["Liquids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Liquid).OrderBy(static d => d.Name).ToList(), + ["Gases"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Gas).OrderBy(static d => d.Name).ToList(), + ["Special"] = special, + }; + } + + private static Dictionary> BuildChunkPrototypeCategories(IParticleLibrary library) + { + var special = library.Definitions.Where(static d => d.IsSpecial).OrderBy(static d => d.Name).ToList(); + if (!special.Any(static d => d.Id == "air")) + { + special.Add(new ParticleDef + { + Id = "air", + Name = "Air", + Kind = ParticleKind.Gas, + IsSpecial = true, + Color = new Rgb24(160, 210, 255), + }); + } + + special = special.OrderBy(static d => d.Name).ToList(); + return new Dictionary> + { + ["Solids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Solid && !d.IsSpecial).OrderBy(static d => d.Name).ToList(), + ["Liquids"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Liquid).OrderBy(static d => d.Name).ToList(), + ["Gases"] = library.Definitions.Where(static d => d.Kind == ParticleKind.Gas).OrderBy(static d => d.Name).ToList(), + ["Special"] = special, + }; + } + + private static long ToMicroseconds(long startTimestamp, long endTimestamp) + { + return (long)((endTimestamp - startTimestamp) * 1_000_000.0 / Stopwatch.Frequency); + } +} diff --git a/Sand.Benchmarks.slnx b/Sand.Benchmarks.slnx new file mode 100644 index 0000000..add9dff --- /dev/null +++ b/Sand.Benchmarks.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/Sand.Benchmarks/Program.cs b/Sand.Benchmarks/Program.cs new file mode 100644 index 0000000..2756ba0 --- /dev/null +++ b/Sand.Benchmarks/Program.cs @@ -0,0 +1,869 @@ +using System.Diagnostics; +using Sand.ChunkPrototype; +using Sand.Core; + +var mode = ParseMode(args); +var headlessGasParticleId = ParseOptionalArgument(args, "--gas-particle", "--particle"); +var contentRoot = Path.Combine(AppContext.BaseDirectory, "Content", "part"); +var library = ParticleLibraryLoader.LoadFromDirectory(contentRoot); +var denseSizes = new[] { 512, 1024, 2048 }; +var appWidth = 265; +var appHeight = 200; +var appWorkloads = BuildAppWorkloads(); + +Console.WriteLine($"Sand benchmarks | mode={mode}"); +Console.WriteLine($"Definitions={library.Definitions.Count}"); +Console.WriteLine("Columns: scenario | backend | world | particles | avg_fps | avg_step_ms | avg_frame_ms | step_core_ms | frame_build_ms | activation_ms | movement_ms | runtime_ms | field_decay_ms | loaded_chunks | active_chunks | stepped_chunks | dirty_chunks | field_cells | move_attempts | successful_moves | swap_attempts | stalled_movable | fast_path | full_runtime | estimated_mb"); +Console.WriteLine(); + +switch (mode) +{ + case "dense-baseline": + RunDenseBaseline(library, denseSizes); + break; + case "chunk-baseline": + RunChunkBaseline(library, denseSizes); + break; + case "app-sized": + RunAppSizedComparisons(library, appWidth, appHeight, appWorkloads); + break; + case "app-headless": + RunHeadlessAppComparisons(library, appWidth, appHeight, headlessGasParticleId); + break; + case "snapshot-scenes": + RunSnapshotScenes(library, appWidth, appHeight, appWorkloads); + break; + default: + RunDenseBaseline(library, denseSizes); + Console.WriteLine(); + RunChunkBaseline(library, denseSizes); + Console.WriteLine(); + RunAppSizedComparisons(library, appWidth, appHeight, appWorkloads); + Console.WriteLine(); + RunHeadlessAppComparisons(library, appWidth, appHeight, headlessGasParticleId); + break; +} + +static string ParseMode(string[] args) +{ + for (var i = 0; i < args.Length; i++) + { + if ((args[i] == "--mode" || args[i] == "-m") && i + 1 < args.Length) + { + return args[i + 1]; + } + } + + return "all"; +} + +static string? ParseOptionalArgument(string[] args, params string[] optionNames) +{ + for (var i = 0; i < args.Length; i++) + { + foreach (var optionName in optionNames) + { + if (args[i] == optionName && i + 1 < args.Length) + { + return args[i + 1]; + } + } + } + + return null; +} + +void RunDenseBaseline(ParticleLibrary library, IReadOnlyList sizes) +{ + Console.WriteLine("Dense baseline"); + foreach (var size in sizes) + { + foreach (var workload in BuildDenseScaleWorkloads()) + { + var result = MeasureDense(library, size, size, workload, includePerStepAction: false); + PrintResult(result); + } + } +} + +void RunChunkBaseline(ParticleLibrary library, IReadOnlyList sizes) +{ + Console.WriteLine("Chunk baseline"); + foreach (var size in sizes) + { + foreach (var workload in BuildChunkScaleWorkloads()) + { + var result = MeasureChunk(library, size, size, workload, includePerStepAction: false); + PrintResult(result); + } + } +} + +void RunAppSizedComparisons(ParticleLibrary library, int width, int height, IReadOnlyList workloads) +{ + Console.WriteLine("App-sized comparisons"); + foreach (var workload in workloads) + { + PrintResult(MeasureDense(library, width, height, workload, includePerStepAction: true)); + PrintResult(MeasureChunk(library, width, height, workload, includePerStepAction: true)); + Console.WriteLine(); + } +} + +void RunHeadlessAppComparisons(ParticleLibrary library, int width, int height, string? preferredGasParticleId) +{ + Console.WriteLine("Headless app-style comparisons"); + var resolvedGasParticleId = ResolveHeadlessGasParticleId(library, preferredGasParticleId); + Console.WriteLine($"Headless gas particle={resolvedGasParticleId}"); + foreach (var workload in BuildHeadlessAppWorkloads(library, resolvedGasParticleId)) + { + PrintResult(MeasureDense(library, width, height, workload, includePerStepAction: true)); + PrintResult(MeasureChunk(library, width, height, workload, includePerStepAction: true)); + Console.WriteLine(); + } +} + +void RunSnapshotScenes(ParticleLibrary library, int width, int height, IReadOnlyList workloads) +{ + Console.WriteLine("Snapshot scenes"); + foreach (var workload in workloads.Where(static workload => workload.IncludeInSnapshots)) + { + var dense120 = BuildDenseSnapshot(library, width, height, workload, 120); + var dense300 = BuildDenseSnapshot(library, width, height, workload, 300); + var chunk120 = BuildChunkSnapshot(library, width, height, workload, 120); + var chunk300 = BuildChunkSnapshot(library, width, height, workload, 300); + Console.WriteLine($"{workload.Name} | dense | step120={dense120} | step300={dense300}"); + Console.WriteLine($"{workload.Name} | chunk | step120={chunk120} | step300={chunk300}"); + } +} + +static BenchmarkResult MeasureDense(ParticleLibrary library, int width, int height, AppWorkload workload, bool includePerStepAction) +{ + var simulation = new SandSimulation(width, height, 1, library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + workload.SetupDense(simulation); + WarmupDense(simulation, workload, includePerStepAction); + var iterations = GetIterationCount(width, height); + var frameBuffer = new byte[width * height * 4]; + var stepAverage = MeasureAverageMilliseconds(() => + { + if (includePerStepAction) + { + workload.TickDense(simulation); + } + + simulation.Step(1f / 60f); + }, iterations); + var frameAverage = MeasureAverageMilliseconds(() => simulation.BuildRgbaFrame(frameBuffer), GetFrameIterationCount(width, height)); + return new BenchmarkResult( + workload.Name, + "dense", + $"{width}x{height}", + simulation.ParticleCount, + stepAverage + frameAverage <= 0.001 ? 0d : 1000d / (stepAverage + frameAverage), + stepAverage, + frameAverage, + stepAverage, + frameAverage, + 0d, + 0d, + 0d, + 0d, + 0, + 0, + 0, + 0, + 0, + 0d, + 0d, + 0d, + 0d, + 0d, + 0d, + EstimateDenseBytes(simulation) / (1024d * 1024d)); +} + +static BenchmarkResult MeasureChunk(ParticleLibrary library, int width, int height, AppWorkload workload, bool includePerStepAction) +{ + var adapter = new PrototypeSparseSandAdapter(width, height); + workload.SetupChunk(adapter, library); + WarmupChunk(adapter, library, workload, includePerStepAction); + var iterations = GetIterationCount(width, height); + var totalStepMs = 0d; + long totalLoadedChunks = 0; + long totalActiveChunks = 0; + long totalSteppedChunks = 0; + long totalDirtyChunks = 0; + long totalFieldCells = 0; + long totalMoveAttempts = 0; + long totalSuccessfulMoves = 0; + long totalSwapAttempts = 0; + long totalStalledMovable = 0; + long totalFastPath = 0; + long totalFullRuntime = 0; + long totalActivationMicros = 0; + long totalMovementMicros = 0; + long totalRuntimeMicros = 0; + long totalFieldDecayMicros = 0; + for (var i = 0; i < iterations; i++) + { + if (includePerStepAction) + { + workload.TickChunk(adapter, library); + } + + adapter.World.ClearDirtyChunks(); + var stepStart = Stopwatch.GetTimestamp(); + adapter.Step(); + totalStepMs += Stopwatch.GetElapsedTime(stepStart).TotalMilliseconds; + var stats = adapter.LastStepStats; + totalLoadedChunks += adapter.World.LoadedChunkCount; + totalActiveChunks += adapter.World.ActiveChunkCount; + totalSteppedChunks += stats.SteppedChunks; + totalDirtyChunks += adapter.World.DirtyChunkCount; + totalFieldCells += adapter.FieldCellCount; + totalMoveAttempts += stats.MoveAttempts; + totalSuccessfulMoves += stats.SuccessfulMoves; + totalSwapAttempts += stats.SwapAttempts; + totalStalledMovable += stats.StalledMovableCells; + totalFastPath += stats.MovementOnlyFastPathCount; + totalFullRuntime += stats.FullRuntimeStepCount; + totalActivationMicros += stats.ActivationTimeMicroseconds; + totalMovementMicros += stats.MovementTimeMicroseconds; + totalRuntimeMicros += stats.RuntimeTimeMicroseconds; + totalFieldDecayMicros += stats.FieldDecayTimeMicroseconds; + } + + var stepAverage = totalStepMs / iterations; + var frameAverage = MeasureAverageMilliseconds(() => adapter.BuildRgbaFrame(), GetFrameIterationCount(width, height)); + return new BenchmarkResult( + workload.Name, + "chunk", + $"{width}x{height}", + adapter.ParticleCount, + stepAverage + frameAverage <= 0.001 ? 0d : 1000d / (stepAverage + frameAverage), + stepAverage, + frameAverage, + stepAverage, + frameAverage, + totalActivationMicros / 1000d / iterations, + totalMovementMicros / 1000d / iterations, + totalRuntimeMicros / 1000d / iterations, + totalFieldDecayMicros / 1000d / iterations, + (int)Math.Round(totalLoadedChunks / (double)iterations), + (int)Math.Round(totalActiveChunks / (double)iterations), + (int)Math.Round(totalSteppedChunks / (double)iterations), + (int)Math.Round(totalDirtyChunks / (double)iterations), + (int)Math.Round(totalFieldCells / (double)iterations), + totalMoveAttempts / (double)iterations, + totalSuccessfulMoves / (double)iterations, + totalSwapAttempts / (double)iterations, + totalStalledMovable / (double)iterations, + totalFastPath / (double)iterations, + totalFullRuntime / (double)iterations, + adapter.EstimatedStorageBytes / (1024d * 1024d)); +} + +static void WarmupDense(SandSimulation simulation, AppWorkload workload, bool includePerStepAction) +{ + for (var i = 0; i < 6; i++) + { + if (includePerStepAction) + { + workload.TickDense(simulation); + } + + simulation.Step(1f / 60f); + } +} + +static void WarmupChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, AppWorkload workload, bool includePerStepAction) +{ + for (var i = 0; i < 6; i++) + { + if (includePerStepAction) + { + workload.TickChunk(adapter, library); + } + + adapter.Step(); + } +} + +static double MeasureAverageMilliseconds(Action action, int iterations) +{ + var stopwatch = Stopwatch.StartNew(); + for (var i = 0; i < iterations; i++) + { + action(); + } + + stopwatch.Stop(); + return stopwatch.Elapsed.TotalMilliseconds / iterations; +} + +static int GetIterationCount(int width, int height) +{ + var area = width * height; + return area switch + { + <= 53000 => 120, + <= 262144 => 24, + <= 1048576 => 10, + _ => 5, + }; +} + +static int GetFrameIterationCount(int width, int height) +{ + var area = width * height; + return area switch + { + <= 53000 => 120, + <= 262144 => 18, + <= 1048576 => 8, + _ => 4, + }; +} + +static void PrintResult(BenchmarkResult result) +{ + Console.WriteLine($"{result.Scenario} | {result.Backend} | {result.World} | {result.Particles} | {result.AvgFpsEquivalent:F2} | {result.AvgStepMs:F3} | {result.AvgFrameMs:F3} | {result.StepCoreMs:F3} | {result.FrameBuildMs:F3} | {result.ActivationMs:F3} | {result.MovementMs:F3} | {result.RuntimeMs:F3} | {result.FieldDecayMs:F3} | {result.LoadedChunks} | {result.ActiveChunks} | {result.SteppedChunks} | {result.DirtyChunks} | {result.FieldCells} | {result.MoveAttempts:F1} | {result.SuccessfulMoves:F1} | {result.SwapAttempts:F1} | {result.StalledMovable:F1} | {result.MovementOnlyFastPathCount:F1} | {result.FullRuntimeStepCount:F1} | {result.EstimatedMb:F3}"); +} + +static string BuildDenseSnapshot(ParticleLibrary library, int width, int height, AppWorkload workload, int steps) +{ + var simulation = new SandSimulation(width, height, 1, library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + workload.SetupDense(simulation); + for (var i = 0; i < steps; i++) + { + workload.TickDense(simulation); + simulation.Step(1f / 60f); + } + + unchecked + { + uint hash = 2166136261; + for (var y = 0; y < simulation.Height; y++) + { + for (var x = 0; x < simulation.Width; x++) + { + var typeId = simulation.TypeId[x, y]; + if (typeId == 0) + { + continue; + } + + hash = (hash ^ (uint)((x * 397) ^ (y * 911) ^ typeId)) * 16777619; + } + } + + return $"{hash:X8}"; + } +} + +static string BuildChunkSnapshot(ParticleLibrary library, int width, int height, AppWorkload workload, int steps) +{ + var adapter = new PrototypeSparseSandAdapter(width, height); + workload.SetupChunk(adapter, library); + for (var i = 0; i < steps; i++) + { + workload.TickChunk(adapter, library); + adapter.Step(); + } + + unchecked + { + uint hash = 2166136261; + foreach (var ((x, y), particle) in adapter.ParticleEntries.OrderBy(static entry => entry.Key.Item2).ThenBy(static entry => entry.Key.Item1)) + { + hash = (hash ^ (uint)((x * 397) ^ (y * 911) ^ particle.TypeId)) * 16777619; + } + + return $"{hash:X8}"; + } +} + +static long EstimateDenseBytes(SandSimulation simulation) +{ + var cells = (long)simulation.Width * simulation.Height; + const int typeBytes = sizeof(ushort); + const int floatBytes = sizeof(float); + const int byteBytes = sizeof(byte); + const int shortBytes = sizeof(short); + const int intBytes = sizeof(int); + var perCellBytes = typeBytes + (12 * floatBytes) + byteBytes + shortBytes + (2 * intBytes) + 3 + 4; + return cells * perCellBytes; +} + +IReadOnlyList BuildDenseScaleWorkloads() => + [ + new AppWorkload("empty", _ => { }, (_, _) => { }, _ => { }, (_, _) => { }, IncludeInSnapshots: false), + new AppWorkload("sparse_rain", CreateSparseRainSceneDense, CreateSparseRainSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true), + new AppWorkload("dense_pile", CreateDensePileSceneDense, CreateDensePileSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: false), + new AppWorkload("gas_pocket", CreateGasPocketSceneDense, CreateGasPocketSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true), + new AppWorkload("tool_stress", CreateToolStressSceneDense, CreateToolStressSceneChunk, TickToolStressDense, TickToolStressChunk, IncludeInSnapshots: true), + ]; + +IReadOnlyList BuildChunkScaleWorkloads() => BuildDenseScaleWorkloads(); + +IReadOnlyList BuildAppWorkloads() => + [ + new AppWorkload("active_sand_flood", _ => { }, (_, _) => { }, TickActiveSandFloodDense, TickActiveSandFloodChunk, IncludeInSnapshots: false), + new AppWorkload("mixed_pile", CreateMixedPileSceneDense, CreateMixedPileSceneChunk, _ => { }, (_, _) => { }, IncludeInSnapshots: true), + new AppWorkload("active_gas_burst", CreateGasPocketSceneDense, CreateGasPocketSceneChunk, TickActiveGasBurstDense, TickActiveGasBurstChunk, IncludeInSnapshots: false), + new AppWorkload("continuous_mixed_paint", _ => { }, (_, _) => { }, TickContinuousMixedPaintDense, TickContinuousMixedPaintChunk, IncludeInSnapshots: false), + new AppWorkload("tool_stress", CreateToolStressSceneDense, CreateToolStressSceneChunk, TickToolStressDense, TickToolStressChunk, IncludeInSnapshots: true), + ]; + +IReadOnlyList BuildHeadlessAppWorkloads(ParticleLibrary library, string gasParticleId) +{ + const int brushRadius = 16; + return + [ + new AppWorkload( + "headless_gas_hold_paint", + _ => { }, + (_, _) => { }, + simulation => TickHeadlessHeldPaintDense(simulation, library, gasParticleId, brushRadius), + (adapter, particleLibrary) => TickHeadlessHeldPaintChunk(adapter, particleLibrary ?? library, gasParticleId, brushRadius), + IncludeInSnapshots: false), + new AppWorkload( + "headless_gas_paint_erase", + _ => { }, + (_, _) => { }, + simulation => TickHeadlessGasPaintEraseDense(simulation, library, gasParticleId, brushRadius), + (adapter, particleLibrary) => TickHeadlessGasPaintEraseChunk(adapter, particleLibrary ?? library, gasParticleId, brushRadius), + IncludeInSnapshots: false), + ]; +} + +static void CreateSparseRainSceneDense(SandSimulation simulation) +{ + foreach (var (x, y) in BuildSparseRainCells(simulation.Width, simulation.Height)) + { + simulation.CreateParticleAtPixel(x, y, "sand"); + } +} + +static void CreateSparseRainSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + foreach (var (x, y) in BuildSparseRainCells(adapter.Width, adapter.Height)) + { + adapter.AddParticle(x, y, PrototypeParticleType.Sand); + } +} + +static void CreateDensePileSceneDense(SandSimulation simulation) +{ + for (var x = 0; x < simulation.Width; x++) + { + simulation.CreateParticleAtPixel(x, simulation.Height - 2, "wall"); + } + + for (var y = simulation.Height / 2; y < simulation.Height - 2; y++) + { + for (var x = simulation.Width / 4; x < (simulation.Width * 3) / 4; x++) + { + simulation.CreateParticleAtPixel(x, y, ((x + y) & 1) == 0 ? "sand" : "water"); + } + } +} + +static void CreateDensePileSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + for (var x = 0; x < adapter.Width; x++) + { + adapter.AddParticle(x, adapter.Height - 2, PrototypeParticleType.Wall); + } + + for (var y = adapter.Height / 2; y < adapter.Height - 2; y++) + { + for (var x = adapter.Width / 4; x < (adapter.Width * 3) / 4; x++) + { + adapter.AddParticle(x, y, ((x + y) & 1) == 0 ? PrototypeParticleType.Sand : PrototypeParticleType.Water); + } + } +} + +static void CreateMixedPileSceneDense(SandSimulation simulation) +{ + for (var x = 0; x < simulation.Width; x++) + { + simulation.CreateParticleAtPixel(x, simulation.Height - 2, "wall"); + } + + for (var x = simulation.Width / 4; x < (simulation.Width * 3) / 4; x++) + { + simulation.CreateParticleAtPixel(x, simulation.Height / 2, "sand"); + simulation.CreateParticleAtPixel(x, simulation.Height / 2 - 18, "water"); + } +} + +static void CreateMixedPileSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + for (var x = 0; x < adapter.Width; x++) + { + adapter.AddParticle(x, adapter.Height - 2, PrototypeParticleType.Wall); + } + + for (var x = adapter.Width / 4; x < (adapter.Width * 3) / 4; x++) + { + adapter.AddParticle(x, adapter.Height / 2, PrototypeParticleType.Sand); + adapter.AddParticle(x, adapter.Height / 2 - 18, PrototypeParticleType.Water); + } +} + +static void CreateGasPocketSceneDense(SandSimulation simulation) +{ + var left = simulation.Width / 3; + var right = (simulation.Width * 2) / 3; + var top = simulation.Height / 3; + var bottom = simulation.Height - 16; + for (var x = left; x <= right; x++) + { + simulation.CreateParticleAtPixel(x, bottom, "wall"); + } + + for (var y = top; y <= bottom; y++) + { + simulation.CreateParticleAtPixel(left, y, "wall"); + simulation.CreateParticleAtPixel(right, y, "wall"); + } + + for (var x = left + 2; x < right - 1; x += 2) + { + for (var y = bottom - 22; y < bottom - 6; y += 2) + { + simulation.CreateParticleAtPixel(x, y, "steam"); + } + } +} + +static void CreateGasPocketSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + var left = adapter.Width / 3; + var right = (adapter.Width * 2) / 3; + var top = adapter.Height / 3; + var bottom = adapter.Height - 16; + for (var x = left; x <= right; x++) + { + adapter.AddParticle(x, bottom, PrototypeParticleType.Wall); + } + + for (var y = top; y <= bottom; y++) + { + adapter.AddParticle(left, y, PrototypeParticleType.Wall); + adapter.AddParticle(right, y, PrototypeParticleType.Wall); + } + + for (var x = left + 2; x < right - 1; x += 2) + { + for (var y = bottom - 22; y < bottom - 6; y += 2) + { + adapter.AddParticle(x, y, PrototypeParticleType.Steam); + } + } +} + +static void CreateToolStressSceneDense(SandSimulation simulation) +{ + CreateMixedPileSceneDense(simulation); + TickToolStressDense(simulation); +} + +static void CreateToolStressSceneChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + CreateMixedPileSceneChunk(adapter, null); + TickToolStressChunk(adapter, null); +} + +static void TickActiveSandFloodDense(SandSimulation simulation) +{ + var x = 8 + ((simulation.Frame * 5) % Math.Max(1, simulation.Width - 40)); + for (var dx = 0; dx < 20; dx += 2) + { + simulation.CreateParticleAtPixel(x + dx, 4, "sand"); + simulation.CreateParticleAtPixel(x + dx, 6, "sand"); + } +} + +static void TickActiveSandFloodChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + var x = 8 + (((adapter.ParticleCount * 5) + (adapter.LastStepStats.MoveAttempts * 3)) % Math.Max(1, adapter.Width - 40)); + for (var dx = 0; dx < 20; dx += 2) + { + adapter.AddParticle(x + dx, 4, PrototypeParticleType.Sand); + adapter.AddParticle(x + dx, 6, PrototypeParticleType.Sand); + } +} + +static void TickContinuousMixedPaintDense(SandSimulation simulation) +{ + var x = 8 + ((simulation.Frame * 7) % Math.Max(1, simulation.Width - 36)); + for (var y = 4; y < 22; y += 3) + { + simulation.CreateParticleAtPixel(x, y, "sand"); + simulation.CreateParticleAtPixel(x + 8, y, "water"); + simulation.CreateParticleAtPixel(x + 16, y, "steam"); + } +} + +static void TickContinuousMixedPaintChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + var x = 8 + (((adapter.ParticleCount * 7) + (adapter.LastStepStats.MoveAttempts * 5)) % Math.Max(1, adapter.Width - 36)); + for (var y = 4; y < 22; y += 3) + { + adapter.AddParticle(x, y, PrototypeParticleType.Sand); + adapter.AddParticle(x + 8, y, PrototypeParticleType.Water); + adapter.AddParticle(x + 16, y, PrototypeParticleType.Steam); + } +} + +static void TickActiveGasBurstDense(SandSimulation simulation) +{ + var centerX = simulation.Width / 2; + var sourceY = simulation.Height - 26; + for (var dx = -10; dx <= 10; dx += 2) + { + simulation.CreateParticleAtPixel(centerX + dx, sourceY, "steam"); + simulation.CreateParticleAtPixel(centerX + dx, sourceY - 2, "steam"); + } +} + +static void TickActiveGasBurstChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + var centerX = adapter.Width / 2; + var sourceY = adapter.Height - 26; + for (var dx = -10; dx <= 10; dx += 2) + { + adapter.AddParticle(centerX + dx, sourceY, PrototypeParticleType.Steam); + adapter.AddParticle(centerX + dx, sourceY - 2, PrototypeParticleType.Steam); + } +} + +static void TickHeadlessHeldPaintDense(SandSimulation simulation, ParticleLibrary library, string particleId, int brushRadius) +{ + var particle = library.GetDefinition(library.GetTypeId(particleId)); + var centerX = (simulation.Width / 2) * 4; + var centerY = (simulation.Height / 4) * 4; + PaintLikeAppDense(simulation, particleId, particle, centerX, centerY, brushRadius); +} + +static void TickHeadlessHeldPaintChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, int brushRadius) +{ + var particle = library.GetDefinition(library.GetTypeId(particleId)); + var centerX = (adapter.Width / 2) * 4; + var centerY = (adapter.Height / 4) * 4; + PaintLikeAppChunk(adapter, library, particleId, particle, centerX, centerY, brushRadius); +} + +static void TickHeadlessGasPaintEraseDense(SandSimulation simulation, ParticleLibrary library, string particleId, int brushRadius) +{ + TickHeadlessHeldPaintDense(simulation, library, particleId, brushRadius); + if ((simulation.Frame & 1) == 0) + { + simulation.ClearParticleCircle((simulation.Width / 2) * 4, (simulation.Height / 2) * 4, Math.Max(4, brushRadius / 2)); + } +} + +static void TickHeadlessGasPaintEraseChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, int brushRadius) +{ + TickHeadlessHeldPaintChunk(adapter, library, particleId, brushRadius); + if ((adapter.LastStepStats.SteppedChunks & 1) == 0) + { + ClearCircleChunk(adapter, adapter.Width / 2, adapter.Height / 2, Math.Max(4, brushRadius / 2)); + } +} + +static void PaintLikeAppDense(SandSimulation simulation, string particleId, ParticleDef particle, int centerX, int centerY, int brushRadius) +{ + if (ShouldUseContinuousPaint(particle)) + { + simulation.CreateParticlePourAtPixel(centerX, centerY, brushRadius, particleId, GetContinuousPaintCount(particle, brushRadius), simulation.Frame); + return; + } + + simulation.CreateParticleCircle(centerX, centerY, brushRadius, particleId); +} + +static void PaintLikeAppChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, string particleId, ParticleDef particle, int centerX, int centerY, int brushRadius) +{ + if (ShouldUseContinuousPaint(particle)) + { + CreateParticlePourChunk(adapter, library, centerX, centerY, brushRadius, particleId, GetContinuousPaintCount(particle, brushRadius), adapter.LastStepStats.SteppedChunks + adapter.ParticleCount); + return; + } + + CreateParticleCircleChunk(adapter, library, centerX, centerY, brushRadius, particleId); +} + +static bool ShouldUseContinuousPaint(ParticleDef particle) => + particle.Kind == ParticleKind.Gas || + particle.Kind == ParticleKind.Liquid || + (particle.Kind == ParticleKind.Solid && !particle.IsStatic); + +static int GetContinuousPaintCount(ParticleDef particle, int brushRadius) => particle.Kind switch +{ + ParticleKind.Gas => Math.Clamp(brushRadius + 1, 1, 8), + ParticleKind.Liquid => Math.Clamp((brushRadius * 2) + 1, 1, 24), + ParticleKind.Solid when !particle.IsStatic => Math.Clamp((brushRadius * 2) + 1, 1, 24), + _ => 0, +}; + +static string ResolveHeadlessGasParticleId(ParticleLibrary library, string? preferredParticleId) +{ + if (!string.IsNullOrWhiteSpace(preferredParticleId) && library.TryGetTypeId(preferredParticleId, out var preferredId) && preferredId != 0) + { + return preferredParticleId; + } + + return library.TryGetTypeId("steam", out var steamId) && steamId != 0 + ? "steam" + : library.TryGetTypeId("ultratanium", out var ultraId) && ultraId != 0 + ? "ultratanium" + : library.Definitions.First(static d => d.Kind == ParticleKind.Gas).Id; +} + +static void CreateParticleCircleChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, int centerX, int centerY, int brushRadius, string particleId) +{ + var gridCenterX = centerX / 4; + var gridCenterY = centerY / 4; + var particle = ResolveChunkParticle(library, adapter, particleId); + for (var dx = -brushRadius; dx <= brushRadius; dx++) + { + for (var dy = -brushRadius; dy <= brushRadius; dy++) + { + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + adapter.AddParticle(gridCenterX + dx, gridCenterY + dy, particle); + } + } +} + +static void CreateParticlePourChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary library, int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) +{ + var gridCenterX = centerX / 4; + var gridCenterY = centerY / 4; + var diameter = (brushRadius * 2) + 1; + var placed = 0; + var particle = ResolveChunkParticle(library, adapter, particleId); + for (var i = 0; i < Math.Max(maxParticles * 6, diameter * diameter) && placed < maxParticles; i++) + { + var dx = ((seed + (i * 17)) % diameter) - brushRadius; + var dy = ((seed + (i * 31)) % diameter) - brushRadius; + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + if (adapter.AddParticle(gridCenterX + dx, gridCenterY + dy, particle)) + { + placed++; + } + } +} + +static void ClearCircleChunk(PrototypeSparseSandAdapter adapter, int centerX, int centerY, int brushRadius) +{ + for (var dx = -brushRadius; dx <= brushRadius; dx++) + { + for (var dy = -brushRadius; dy <= brushRadius; dy++) + { + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + adapter.RemoveParticle(centerX + dx, centerY + dy); + } + } +} + +static PrototypeParticle ResolveChunkParticle(ParticleLibrary library, PrototypeSparseSandAdapter adapter, string particleId) +{ + var typeId = library.GetTypeId(particleId); + var def = library.GetDefinition(typeId); + var color = def.Color; + var motionType = def.Kind switch + { + ParticleKind.Solid => def.IsStatic ? PrototypeParticleType.Wall : PrototypeParticleType.Sand, + ParticleKind.Liquid => PrototypeParticleType.Water, + ParticleKind.Gas => PrototypeParticleType.Steam, + _ => PrototypeParticleType.Sand, + }; + var particle = new PrototypeParticle(typeId, def.Id, motionType, def.Kind, ParticleBehaviorKind.None, color.R, color.G, color.B, MathF.Max(0.1f, def.Mass), MathF.Max(0.01f, def.Velocity), MathF.Max(0f, def.Friction), MathF.Max(0f, def.Viscosity), IsStatic: def.IsStatic); + adapter.RegisterParticleProfile(particle); + return particle; +} + +static void TickToolStressDense(SandSimulation simulation) +{ + var centerX = simulation.Width / 2; + var centerY = simulation.Height / 2; + simulation.ApplyWindBrushAtPixel(centerX, centerY, 8, 1f, 0.2f); + simulation.ApplyAirBrushAtPixel(centerX + 12, centerY - 6, 7, 0.75f, 0f); + simulation.ApplyGravityBrushAtPixel(centerX - 10, centerY + 4, 6, 2.5f); +} + +static void TickToolStressChunk(PrototypeSparseSandAdapter adapter, ParticleLibrary? _) +{ + var centerX = adapter.Width / 2; + var centerY = adapter.Height / 2; + adapter.ApplyWindBrush(centerX, centerY, 8, 1f, 0.2f); + adapter.ApplyAirBrush(centerX + 12, centerY - 6, 7, 0.75f, 0f); + adapter.ApplyGravityBrush(centerX - 10, centerY + 4, 6, 2.5f); +} + +static List<(int X, int Y)> BuildSparseRainCells(int width, int height) +{ + var cells = new List<(int X, int Y)>(); + for (var x = 4; x < width - 4; x += Math.Max(10, width / 24)) + { + cells.Add((x, 4 + ((x / 7) % 5))); + cells.Add((x, 12 + ((x / 11) % 7))); + if (height > 40) + { + cells.Add((x, 24 + ((x / 5) % 9))); + } + } + + return cells; +} + +internal readonly record struct AppWorkload( + string Name, + Action SetupDense, + Action SetupChunk, + Action TickDense, + Action TickChunk, + bool IncludeInSnapshots); + +internal readonly record struct BenchmarkResult( + string Scenario, + string Backend, + string World, + int Particles, + double AvgFpsEquivalent, + double AvgStepMs, + double AvgFrameMs, + double StepCoreMs, + double FrameBuildMs, + double ActivationMs, + double MovementMs, + double RuntimeMs, + double FieldDecayMs, + int LoadedChunks, + int ActiveChunks, + int SteppedChunks, + int DirtyChunks, + int FieldCells, + double MoveAttempts, + double SuccessfulMoves, + double SwapAttempts, + double StalledMovable, + double MovementOnlyFastPathCount, + double FullRuntimeStepCount, + double EstimatedMb); diff --git a/Sand.Benchmarks/README.md b/Sand.Benchmarks/README.md new file mode 100644 index 0000000..f3e4e44 --- /dev/null +++ b/Sand.Benchmarks/README.md @@ -0,0 +1,46 @@ +# Sand Benchmarks + +This project measures both the dense reference backend and the experimental chunk backend. + +## Modes + +- `dense-baseline` +- `chunk-baseline` +- `app-sized` +- `snapshot-scenes` + +## Main workloads + +- `empty` +- `sparse_rain` +- `dense_pile` +- `gas_pocket` +- `tool_stress` +- app-sized `sand_fill` +- app-sized `mixed_pile` +- app-sized `paint_stress` + +## Metrics + +- average `Step` time +- average `BuildRgbaFrame` time +- app-sized FPS-equivalent comparison numbers +- chunk workload counters (`loaded`, `active`, `stepped`, `dirty`, `field_cells`) +- estimated storage footprint + +## Run + +```powershell +dotnet run --project Sand.Benchmarks/Sand.Benchmarks.csproj -c Release -- --mode app-sized +``` + +```powershell +dotnet run --project Sand.Benchmarks/Sand.Benchmarks.csproj -c Release -- --mode snapshot-scenes +``` + +## Promotion gate + +- sparse step speedup must be `>= 25%` +- sparse memory reduction at `1024x1024+` must be `>= 40%` +- dense `512x512` regression must stay within `10%` +- deterministic snapshots must be reviewed after `120` and `300` steps before storage promotion diff --git a/Sand.Benchmarks/Sand.Benchmarks.csproj b/Sand.Benchmarks/Sand.Benchmarks.csproj new file mode 100644 index 0000000..4d2386b --- /dev/null +++ b/Sand.Benchmarks/Sand.Benchmarks.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + + + + + + + + + + + diff --git a/Sand.ChunkPrototype.App/Program.cs b/Sand.ChunkPrototype.App/Program.cs new file mode 100644 index 0000000..3a54eae --- /dev/null +++ b/Sand.ChunkPrototype.App/Program.cs @@ -0,0 +1,168 @@ +using Raylib_cs; +using Sand.ChunkPrototype; + +const int screenWidth = 1280; +const int screenHeight = 800; +const int sidebarWidth = 220; +const int particleSize = 4; +const int simWidth = (screenWidth - sidebarWidth) / particleSize; +const int simHeight = screenHeight / particleSize; +const int brushRadius = 3; + +var adapter = new PrototypeSparseSandAdapter(simWidth, simHeight); +var accumulator = 0f; +var fixedStep = 1f / 60f; +var stepCounter = 0; +var currentParticle = PrototypeParticleType.Sand; +var frameBuffer = new byte[simWidth * simHeight * 4]; + +Raylib.SetConfigFlags(ConfigFlags.ResizableWindow); +Raylib.InitWindow(screenWidth, screenHeight, "Sand Chunk Prototype"); +Raylib.SetTargetFPS(120); +var frameImage = Raylib.GenImageColor(simWidth, simHeight, Color.Black); +var frameTexture = Raylib.LoadTextureFromImage(frameImage); +Raylib.UnloadImage(frameImage); + +while (!Raylib.WindowShouldClose()) +{ + var dt = Raylib.GetFrameTime(); + var mouse = Raylib.GetMousePosition(); + var simMouseX = Math.Clamp((int)((mouse.X - sidebarWidth) / particleSize), 0, simWidth - 1); + var simMouseY = Math.Clamp((int)(mouse.Y / particleSize), 0, simHeight - 1); + var overWorld = mouse.X >= sidebarWidth; + + if (Raylib.IsKeyPressed(KeyboardKey.C)) + { + adapter = new PrototypeSparseSandAdapter(simWidth, simHeight); + accumulator = 0f; + stepCounter = 0; + } + + if (Raylib.IsKeyPressed(KeyboardKey.One)) + { + currentParticle = PrototypeParticleType.Sand; + } + + if (Raylib.IsKeyPressed(KeyboardKey.Two)) + { + currentParticle = PrototypeParticleType.Water; + } + + if (Raylib.IsKeyPressed(KeyboardKey.Three)) + { + currentParticle = PrototypeParticleType.Steam; + } + + if (Raylib.IsKeyPressed(KeyboardKey.Four)) + { + currentParticle = PrototypeParticleType.Wall; + } + + if (Raylib.IsMouseButtonDown(MouseButton.Left) && overWorld) + { + PaintCircle(adapter, simMouseX, simMouseY, brushRadius, currentParticle, add: true); + } + + if (Raylib.IsMouseButtonDown(MouseButton.Right) && overWorld) + { + PaintCircle(adapter, simMouseX, simMouseY, brushRadius, currentParticle, add: false); + } + + accumulator += dt; + var maxSteps = 8; + while (accumulator >= fixedStep && maxSteps-- > 0) + { + adapter.World.ClearDirtyChunks(); + adapter.Step(); + stepCounter++; + if ((stepCounter % 30) == 0) + { + adapter.TrimResidency(marginChunks: 1); + } + + accumulator -= fixedStep; + } + + if (accumulator > fixedStep * 4f) + { + accumulator = fixedStep; + } + + var rgbaFrame = adapter.BuildRgbaFrame(); + rgbaFrame.CopyTo(frameBuffer); + unsafe + { + fixed (byte* pixels = frameBuffer) + { + Raylib.UpdateTexture(frameTexture, pixels); + } + } + + Raylib.BeginDrawing(); + Raylib.ClearBackground(new Color(8, 10, 14, 255)); + Raylib.DrawRectangle(0, 0, sidebarWidth, screenHeight, new Color(20, 24, 30, 255)); + Raylib.DrawTextureEx(frameTexture, new System.Numerics.Vector2(sidebarWidth, 0), 0f, particleSize, Color.White); + if (overWorld) + { + Raylib.DrawCircleLines((int)mouse.X, (int)mouse.Y, brushRadius * particleSize, new Color(255, 255, 255, 180)); + } + + DrawPanel(adapter, currentParticle); + Raylib.EndDrawing(); +} + +Raylib.UnloadTexture(frameTexture); +Raylib.CloseWindow(); + +static void PaintCircle(PrototypeSparseSandAdapter adapter, int centerX, int centerY, int radius, PrototypeParticleType type, bool add) +{ + for (var dx = -radius; dx <= radius; dx++) + { + for (var dy = -radius; dy <= radius; dy++) + { + if ((dx * dx) + (dy * dy) > radius * radius) + { + continue; + } + + var x = centerX + dx; + var y = centerY + dy; + if (x < 0 || y < 0 || x >= adapter.Width || y >= adapter.Height) + { + continue; + } + + if (add) + { + adapter.AddParticle(x, y, type); + } + else + { + adapter.RemoveParticle(x, y); + } + } + } +} + +static void DrawPanel(PrototypeSparseSandAdapter adapter, PrototypeParticleType currentParticle) +{ + Raylib.DrawText("Chunk Proto", 12, 14, 28, new Color(110, 172, 228, 255)); + DrawParticleButton(12, 60, "1 Sand", currentParticle == PrototypeParticleType.Sand, new Color(214, 188, 96, 255)); + DrawParticleButton(12, 96, "2 Water", currentParticle == PrototypeParticleType.Water, new Color(72, 132, 232, 255)); + DrawParticleButton(12, 132, "3 Steam", currentParticle == PrototypeParticleType.Steam, new Color(182, 196, 214, 255)); + DrawParticleButton(12, 168, "4 Wall", currentParticle == PrototypeParticleType.Wall, new Color(96, 96, 104, 255)); + Raylib.DrawText($"FPS {Raylib.GetFPS()}", 12, 228, 20, Color.White); + Raylib.DrawText($"Particles {adapter.ParticleCount}", 12, 252, 20, Color.White); + Raylib.DrawText($"Loaded Chunks {adapter.World.LoadedChunkCount}", 12, 276, 20, Color.White); + Raylib.DrawText($"Active Chunks {adapter.World.ActiveChunkCount}", 12, 300, 20, Color.White); + Raylib.DrawText($"Dirty Chunks {adapter.World.DirtyChunkCount}", 12, 324, 20, Color.White); + Raylib.DrawText("LMB paint", 12, 372, 18, Color.LightGray); + Raylib.DrawText("RMB erase", 12, 394, 18, Color.LightGray); + Raylib.DrawText("C clear", 12, 416, 18, Color.LightGray); +} + +static void DrawParticleButton(int x, int y, string label, bool selected, Color color) +{ + Raylib.DrawRectangleRounded(new Rectangle(x, y, 188, 28), 0.2f, 6, selected ? new Color(236, 236, 242, 255) : color); + Raylib.DrawText(label, x + 10, y + 5, 18, selected ? Color.Black : Color.Black); +} diff --git a/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj b/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj new file mode 100644 index 0000000..d011af3 --- /dev/null +++ b/Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj @@ -0,0 +1,17 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + + + + + diff --git a/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs b/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs new file mode 100644 index 0000000..890edeb --- /dev/null +++ b/Sand.ChunkPrototype.Tests/PrototypeChunkResidencyWorldTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using Sand.ChunkPrototype; + +namespace Sand.ChunkPrototype.Tests; + +public sealed class PrototypeChunkResidencyWorldTests +{ + [Fact] + public void MoveOccupiedAcrossChunkBoundaryUpdatesCountsAndDirtyChunks() + { + var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 16)); + world.SetOccupied(3, 1); + world.ClearDirtyChunks(); + + var moved = world.MoveOccupied(3, 1, 4, 1); + + moved.Should().BeTrue(); + world.IsOccupied(3, 1).Should().BeFalse(); + world.IsOccupied(4, 1).Should().BeTrue(); + world.GetChunkOccupancyCount(0, 0).Should().Be(0); + world.GetChunkOccupancyCount(1, 0).Should().Be(1); + world.DirtyChunks.Should().BeEquivalentTo([(0, 0), (1, 0)]); + } + + [Fact] + public void UnloadEmptyChunksRemovesChunksAfterClearingOccupancy() + { + var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 16)); + world.SetOccupied(1, 1); + world.SetOccupied(9, 1); + + world.ClearOccupied(1, 1); + + var unloaded = world.UnloadEmptyChunks(); + + unloaded.Should().Be(1); + world.IsChunkLoaded(0, 0).Should().BeFalse(); + world.IsChunkLoaded(2, 0).Should().BeTrue(); + world.ActiveChunkCount.Should().Be(1); + } + + [Fact] + public void UnloadInactiveChunksKeepsActiveNeighborsWithinMargin() + { + var world = new PrototypeChunkResidencyWorld(new ChunkResidencyConfig(4, 4, Capacity: 32)); + world.SetOccupied(4, 4); + world.SetOccupied(20, 20); + world.ClearOccupied(20, 20); + world.SetOccupied(8, 4); + + var unloaded = world.UnloadInactiveChunks(marginChunks: 1); + + unloaded.Should().BeGreaterThanOrEqualTo(1); + world.IsChunkLoaded(1, 1).Should().BeTrue(); + world.IsChunkLoaded(2, 1).Should().BeTrue(); + world.IsChunkLoaded(5, 5).Should().BeFalse(); + } + + [Fact] + public void ClearDirtyChunksResetsDirtyChunkTracking() + { + var world = new PrototypeChunkResidencyWorld(); + world.SetOccupied(2, 2); + world.SetOccupied(40, 2); + world.DirtyChunkCount.Should().Be(2); + + world.ClearDirtyChunks(); + + world.DirtyChunkCount.Should().Be(0); + world.DirtyChunks.Should().BeEmpty(); + } +} diff --git a/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs b/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs new file mode 100644 index 0000000..ca9d35f --- /dev/null +++ b/Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs @@ -0,0 +1,525 @@ +using FluentAssertions; +using Sand.ChunkPrototype; +using Sand.Core; + +namespace Sand.ChunkPrototype.Tests; + +public sealed class PrototypeSparseSandAdapterTests +{ + [Fact] + public void StepDownMovesParticlesIntoEmptySpace() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(3, 2); + + var moves = adapter.StepDown(); + + moves.Should().Be(1); + adapter.HasParticle(3, 2).Should().BeFalse(); + adapter.HasParticle(3, 3).Should().BeTrue(); + adapter.World.IsOccupied(3, 3).Should().BeTrue(); + adapter.LastStepStats.MovementOnlyFastPathCount.Should().Be(1); + adapter.LastStepStats.FullRuntimeStepCount.Should().Be(0); + adapter.LastStepStats.MoveAttempts.Should().BeGreaterThan(0); + adapter.LastStepStats.SuccessfulMoves.Should().Be(1); + } + + [Fact] + public void StepDownStopsAtBottomBoundary() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(3, 15); + + var moves = adapter.StepDown(); + + moves.Should().Be(0); + adapter.HasParticle(3, 15).Should().BeTrue(); + } + + [Fact] + public void WaterFlowsSidewaysWhenBlockedBelow() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 8, PrototypeParticleType.Water); + adapter.AddParticle(8, 9, PrototypeParticleType.Wall); + adapter.AddParticle(7, 9, PrototypeParticleType.Wall); + adapter.AddParticle(9, 9, PrototypeParticleType.Wall); + + var moves = adapter.Step(); + + moves.Should().Be(1); + (adapter.GetParticleTypeAt(7, 8) == PrototypeParticleType.Water || adapter.GetParticleTypeAt(9, 8) == PrototypeParticleType.Water).Should().BeTrue(); + } + + [Fact] + public void SteamRisesWhenSpaceAboveIsEmpty() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 8, PrototypeParticleType.Steam); + + var moves = adapter.Step(); + + moves.Should().Be(1); + adapter.GetParticleTypeAt(8, 7).Should().Be(PrototypeParticleType.Steam); + } + + [Fact] + public void SteamCrossingHorizontalChunkBoundaryDoesNotPauseBehindUpperChunkGas() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(2, 3, PrototypeParticleType.Steam); + adapter.AddParticle(2, 4, PrototypeParticleType.Steam); + adapter.AddParticle(1, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(1, 4, PrototypeParticleType.Wall); + adapter.AddParticle(3, 4, PrototypeParticleType.Wall); + + var moves = adapter.Step(); + + moves.Should().Be(2); + adapter.GetParticleTypeAt(2, 2).Should().Be(PrototypeParticleType.Steam); + adapter.GetParticleTypeAt(2, 3).Should().Be(PrototypeParticleType.Steam); + adapter.GetParticleTypeAt(2, 4).Should().Be(PrototypeParticleType.Empty); + } + + [Fact] + public void DenseSteamColumnAcrossHorizontalChunkBoundaryDoesNotLeaveGapRow() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(2, 3, PrototypeParticleType.Steam); + adapter.AddParticle(2, 4, PrototypeParticleType.Steam); + adapter.AddParticle(2, 5, PrototypeParticleType.Steam); + adapter.AddParticle(1, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(1, 4, PrototypeParticleType.Wall); + adapter.AddParticle(3, 4, PrototypeParticleType.Wall); + adapter.AddParticle(1, 5, PrototypeParticleType.Wall); + adapter.AddParticle(3, 5, PrototypeParticleType.Wall); + + var moves = adapter.Step(); + + moves.Should().Be(3); + adapter.GetParticleTypeAt(2, 2).Should().Be(PrototypeParticleType.Steam); + adapter.GetParticleTypeAt(2, 3).Should().Be(PrototypeParticleType.Steam); + adapter.GetParticleTypeAt(2, 4).Should().Be(PrototypeParticleType.Steam); + } + + [Fact] + public void GasSeamFrameClearsOldBorderPixelAfterColumnCompaction() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(2, 3, PrototypeParticleType.Steam); + adapter.AddParticle(2, 4, PrototypeParticleType.Steam); + adapter.AddParticle(2, 5, PrototypeParticleType.Steam); + adapter.AddParticle(1, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(1, 4, PrototypeParticleType.Wall); + adapter.AddParticle(3, 4, PrototypeParticleType.Wall); + adapter.AddParticle(1, 5, PrototypeParticleType.Wall); + adapter.AddParticle(3, 5, PrototypeParticleType.Wall); + + adapter.Step(); + + var frame = adapter.BuildRgbaFrame().ToArray(); + var clearedIndex = ((5 * 8) + 2) * 4; + frame[clearedIndex].Should().Be(0); + frame[clearedIndex + 1].Should().Be(0); + frame[clearedIndex + 2].Should().Be(0); + } + + [Fact] + public void GasRenderFillsChunkRowSeamWhenCloudExistsAboveAndBelow() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(2, 2, PrototypeParticleType.Steam); + adapter.AddParticle(2, 4, PrototypeParticleType.Steam); + adapter.AddParticle(1, 3, PrototypeParticleType.Steam); + adapter.AddParticle(3, 3, PrototypeParticleType.Steam); + + var frame = adapter.BuildRgbaFrame().ToArray(); + + var seamIndex = ((3 * 8) + 2) * 4; + frame[seamIndex].Should().Be(182); + frame[seamIndex + 1].Should().Be(196); + frame[seamIndex + 2].Should().Be(214); + } + + [Fact] + public void WallRemainsStaticDuringStep() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 8, PrototypeParticleType.Wall); + + var moves = adapter.Step(); + + moves.Should().Be(0); + adapter.GetParticleTypeAt(8, 8).Should().Be(PrototypeParticleType.Wall); + } + + [Fact] + public void StepDownCrossesChunkBoundaryAndKeepsCountsConsistent() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(3, 3); + adapter.World.ClearDirtyChunks(); + + var moves = adapter.StepDown(); + + moves.Should().Be(1); + adapter.HasParticle(3, 4).Should().BeTrue(); + adapter.World.GetChunkOccupancyCount(0, 0).Should().Be(0); + adapter.World.GetChunkOccupancyCount(0, 1).Should().Be(1); + adapter.World.DirtyChunks.Should().BeEquivalentTo([(0, 0), (0, 1)]); + adapter.LastStepStats.SuccessfulMoves.Should().Be(1); + adapter.LastStepStats.SwapAttempts.Should().Be(0); + adapter.LastStepStats.MoveAttempts.Should().BeGreaterThan(0); + } + + [Fact] + public void TrimResidencyUnloadsDistantEmptyChunksAfterMovement() + { + var adapter = new PrototypeSparseSandAdapter(64, 64, new ChunkResidencyConfig(4, 4, Capacity: 64)); + adapter.AddParticle(3, 3); + adapter.AddParticle(40, 3); + adapter.RemoveParticle(40, 3); + + var unloaded = adapter.TrimResidency(marginChunks: 0); + + unloaded.Should().BeGreaterThanOrEqualTo(1); + adapter.World.IsChunkLoaded(10, 0).Should().BeFalse(); + adapter.World.IsChunkLoaded(0, 0).Should().BeTrue(); + } + + [Fact] + public void BuildRgbaFrameIncludesDistinctColorsForParticleTypes() + { + var adapter = new PrototypeSparseSandAdapter(8, 8); + adapter.AddParticle(1, 1, PrototypeParticleType.Sand); + adapter.AddParticle(2, 1, PrototypeParticleType.Water); + adapter.AddParticle(3, 1, PrototypeParticleType.Steam); + adapter.AddParticle(4, 1, PrototypeParticleType.Wall); + + var frame = adapter.BuildRgbaFrame().ToArray(); + + frame[((1 * 8) + 1) * 4].Should().Be(214); + frame[((1 * 8) + 2) * 4].Should().Be(72); + frame[((1 * 8) + 3) * 4].Should().Be(182); + frame[((1 * 8) + 4) * 4].Should().Be(96); + } + + [Fact] + public void CustomParticleProfilePreservesColorAndTypeId() + { + var adapter = new PrototypeSparseSandAdapter(8, 8); + var particle = new PrototypeParticle(99, "custom_water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 12, 34, 56, 1.2f, 0.4f, 0.1f, 0.2f); + + adapter.AddParticle(2, 3, particle).Should().BeTrue(); + + adapter.GetTypeIdAt(2, 3).Should().Be(99); + var frame = adapter.BuildRgbaFrame().ToArray(); + var index = ((3 * 8) + 2) * 4; + frame[index].Should().Be(12); + frame[index + 1].Should().Be(34); + frame[index + 2].Should().Be(56); + } + + [Fact] + public void WindBrushPushesGasSidewaysWhenUpwardPathIsBlocked() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 8, PrototypeParticleType.Steam); + adapter.AddParticle(8, 7, PrototypeParticleType.Wall); + adapter.AddParticle(7, 7, PrototypeParticleType.Wall); + adapter.AddParticle(9, 7, PrototypeParticleType.Wall); + adapter.ApplyWindBrush(8, 8, 3, 1f, 0f); + adapter.ApplyWindBrush(8, 8, 3, 1f, 0f); + + var moves = adapter.Step(); + + moves.Should().Be(1); + adapter.GetParticleTypeAt(9, 8).Should().Be(PrototypeParticleType.Steam); + } + + [Fact] + public void SolidContinuesSettlingAcrossIdleStepsWithoutExternalWorldChanges() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 6, PrototypeParticleType.Sand); + adapter.AddParticle(8, 7, PrototypeParticleType.Wall); + + var moved = false; + for (var i = 0; i < 64; i++) + { + if (adapter.Step() > 0) + { + moved = true; + break; + } + } + + moved.Should().BeTrue(); + adapter.HasParticle(8, 6).Should().BeFalse(); + } + + [Fact] + public void WaterKeepsSurfaceDirectionInsteadOfImmediateBacktracking() + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(8, 8, PrototypeParticleType.Water); + adapter.AddParticle(7, 9, PrototypeParticleType.Wall); + adapter.AddParticle(8, 9, PrototypeParticleType.Wall); + adapter.AddParticle(9, 9, PrototypeParticleType.Wall); + + adapter.Step().Should().Be(1); + for (var i = 0; i < 4; i++) + { + adapter.Step(); + } + + adapter.GetParticleTypeAt(8, 8).Should().Be(PrototypeParticleType.Empty); + } + + [Fact] + public void WaterHydratesSandIntoWetSand() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var wetSand = new PrototypeParticle(101, "wet_sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 120, 96, 64, 1.6f, 0.45f, 0.24f, 0.18f); + var sand = new PrototypeParticle(100, "sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 214, 188, 96, 1.4f, 0.65f, 0.18f, 0.08f, HydrateTargetTypeId: 101); + var water = new PrototypeParticle(102, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike); + adapter.RegisterParticleProfile(wetSand); + + adapter.AddParticle(2, 2, sand); + adapter.AddParticle(1, 2, water); + adapter.AddParticle(1, 3, PrototypeParticleType.Wall); + adapter.AddParticle(2, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(0, 2, PrototypeParticleType.Wall); + adapter.AddParticle(0, 3, PrototypeParticleType.Wall); + + adapter.Step(); + + adapter.GetTypeIdAt(2, 2).Should().Be(101); + adapter.GetTypeIdAt(1, 2).Should().Be(0); + } + + [Fact] + public void FullRuntimeParticleIncrementsFullRuntimeCounter() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var runtimeParticle = new PrototypeParticle(150, "runtime_sand", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 180, 150, 90, 1.2f, 0.4f, 0.15f, 0.05f, DefaultLifetime: 20f); + adapter.RegisterParticleProfile(runtimeParticle); + adapter.AddParticle(3, 2, runtimeParticle); + + adapter.Step(); + + adapter.LastStepStats.FullRuntimeStepCount.Should().Be(1); + adapter.LastStepStats.MovementOnlyFastPathCount.Should().Be(0); + } + + [Fact] + public void StalledMovableCounterIncrementsWhenOpenPathExistsButSolidDoesNotSlip() + { + var foundStall = false; + for (var x = 2; x < 14 && !foundStall; x++) + { + var adapter = new PrototypeSparseSandAdapter(16, 16, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(x, 6, PrototypeParticleType.Sand); + adapter.AddParticle(x, 7, PrototypeParticleType.Wall); + + adapter.Step(); + + if (adapter.LastStepStats.StalledMovableCells > 0) + { + foundStall = true; + } + } + + foundStall.Should().BeTrue(); + } + + [Fact] + public void LavaAndWaterReactIntoStoneAndSteam() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var stone = new PrototypeParticle(201, "stone", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 96, 96, 96, 1.8f, 0.25f, 0.5f, 0.4f, IsStatic: true); + var steam = new PrototypeParticle(202, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 203, PressureThreshold: 1.2f, InitialTemperature: 110f); + var water = new PrototypeParticle(203, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike, EvaporateTypeId: 202); + var lava = new PrototypeParticle(204, "lava", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 255, 96, 24, 2f, 0.35f, 0.18f, 0.45f, Flags: PrototypeParticleFlags.HotSource, IsMolten: true, SolidifyTypeId: 201); + adapter.RegisterParticleProfile(stone); + adapter.RegisterParticleProfile(steam); + + adapter.AddParticle(2, 2, lava); + adapter.AddParticle(3, 2, water); + adapter.AddParticle(1, 2, PrototypeParticleType.Wall); + adapter.AddParticle(4, 2, PrototypeParticleType.Wall); + adapter.AddParticle(2, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(4, 3, PrototypeParticleType.Wall); + + adapter.Step(); + + adapter.GetTypeIdAt(2, 2).Should().Be(201); + adapter.GetTypeIdAt(3, 2).Should().Be(202); + } + + [Fact] + public void HotNeighborEvaporatesWaterIntoSteam() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var steam = new PrototypeParticle(301, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, InitialTemperature: 110f); + var water = new PrototypeParticle(302, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike, EvaporateTypeId: 301); + var plasma = new PrototypeParticle(303, "plasma", PrototypeParticleType.Wall, ParticleKind.Solid, ParticleBehaviorKind.Plasma, 255, 64, 180, 10f, 0f, 1f, 1f, IsStatic: true, Flags: PrototypeParticleFlags.FireLike | PrototypeParticleFlags.HotSource); + adapter.RegisterParticleProfile(steam); + + adapter.AddParticle(2, 2, water); + adapter.AddParticle(3, 2, plasma); + adapter.AddParticle(1, 2, PrototypeParticleType.Wall); + adapter.AddParticle(2, 3, PrototypeParticleType.Wall); + adapter.AddParticle(1, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + + adapter.Step(); + + (adapter.GetTypeIdAt(2, 2) == 301 || adapter.GetTypeIdAt(2, 1) == 301).Should().BeTrue(); + } + + [Fact] + public void PressureCanBreakStaticParticleIntoConfiguredTarget() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var rubble = new PrototypeParticle(401, "rubble", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 160, 140, 120, 1.2f, 0.4f, 0.22f, 0.2f); + var brittle = new PrototypeParticle(402, "brittle", PrototypeParticleType.Wall, ParticleKind.Solid, ParticleBehaviorKind.None, 210, 210, 210, 5f, 0f, 1f, 1f, IsStatic: true, BrokenTypeId: 401, PressureThreshold: 0.35f, PressureThresholdDuration: 1); + adapter.RegisterParticleProfile(rubble); + + adapter.AddParticle(4, 4, brittle); + adapter.ApplyAirBrush(4, 4, 2, 0f, 12f); + adapter.ApplyAirBrush(4, 4, 2, 0f, 12f); + + adapter.Step(); + + adapter.GetTypeIdAt(4, 4).Should().Be(401); + } + + [Fact] + public void PressurizedSteamCanCondenseIntoWater() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var water = new PrototypeParticle(501, "water", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike); + var steam = new PrototypeParticle(502, "steam", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 501, PressureThreshold: 0.6f, InitialTemperature: 110f); + adapter.RegisterParticleProfile(water); + + adapter.AddParticle(4, 4, steam); + adapter.AddParticle(4, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(5, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 4, PrototypeParticleType.Wall); + adapter.AddParticle(5, 4, PrototypeParticleType.Wall); + adapter.AddParticle(4, 5, PrototypeParticleType.Wall); + adapter.ApplyAirBrush(4, 4, 1, 0f, 4f); + + adapter.Step(); + + adapter.GetTypeIdAt(4, 4).Should().Be(501); + } + + [Fact] + public void BlockedGasRiseDoesNotKeepRetryingVerticalProbeWhenCeilingIsUnchanged() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(4, 4, PrototypeParticleType.Steam); + adapter.AddParticle(4, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 3, PrototypeParticleType.Wall); + adapter.AddParticle(5, 3, PrototypeParticleType.Wall); + adapter.AddParticle(3, 4, PrototypeParticleType.Wall); + adapter.AddParticle(5, 4, PrototypeParticleType.Wall); + + adapter.Step(); + adapter.Step(); + + adapter.LastStepStats.VerticalMoveAttempts.Should().Be(0); + } + + [Fact] + public void StableHotGasCanSkipFullRuntimeOnSomeSteps() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var hotGas = new PrototypeParticle(550, "hot_gas", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 220, 180, 220, 0.25f, 0.45f, 0.02f, 0.03f, InitialTemperature: 1000f, Conductivity: 1f); + adapter.RegisterParticleProfile(hotGas); + + adapter.AddParticle(2, 5, hotGas); + adapter.AddParticle(3, 5, hotGas); + adapter.AddParticle(2, 6, hotGas); + adapter.AddParticle(3, 6, hotGas); + + var observedReducedRuntime = false; + for (var i = 0; i < 4; i++) + { + adapter.Step(); + if (adapter.LastStepStats.FullRuntimeGasCount < 4) + { + observedReducedRuntime = true; + break; + } + } + + observedReducedRuntime.Should().BeTrue(); + } + + [Fact] + public void StableHotGasAdjacentToWallCanStillSkipFullRuntime() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var hotGas = new PrototypeParticle(551, "hot_wall_gas", PrototypeParticleType.Steam, ParticleKind.Gas, ParticleBehaviorKind.None, 220, 180, 220, 0.25f, 0.45f, 0.02f, 0.03f, InitialTemperature: 1000f, Conductivity: 1f); + adapter.RegisterParticleProfile(hotGas); + + adapter.AddParticle(3, 5, hotGas); + adapter.AddParticle(4, 5, hotGas); + adapter.AddParticle(2, 5, PrototypeParticleType.Wall); + adapter.AddParticle(5, 5, PrototypeParticleType.Wall); + + var observedReducedRuntime = false; + for (var i = 0; i < 4; i++) + { + adapter.Step(); + if (adapter.LastStepStats.FullRuntimeGasCount < 2) + { + observedReducedRuntime = true; + break; + } + } + + observedReducedRuntime.Should().BeTrue(); + } + + [Fact] + public void AmbientSolidDoesNotInstantlyMeltJustBecauseItHasMeltTarget() + { + var adapter = new PrototypeSparseSandAdapter(8, 8, new ChunkResidencyConfig(4, 4, Capacity: 16)); + var molten = new PrototypeParticle(601, "molten_test", PrototypeParticleType.Water, ParticleKind.Liquid, ParticleBehaviorKind.None, 255, 96, 24, 2f, 0.35f, 0.18f, 0.45f, IsMolten: true, InitialTemperature: 140f); + var solid = new PrototypeParticle(602, "solid_test", PrototypeParticleType.Sand, ParticleKind.Solid, ParticleBehaviorKind.None, 140, 140, 140, 1.8f, 0.25f, 0.45f, 0.35f, MeltTypeId: 601, MeltTemperature: 900f, InitialTemperature: 22f); + adapter.RegisterParticleProfile(molten); + + adapter.AddParticle(4, 4, solid); + adapter.AddParticle(4, 5, PrototypeParticleType.Wall); + + adapter.Step(); + + adapter.GetTypeIdAt(4, 4).Should().Be(602); + } + + [Fact] + public void SteamContinuesRisingAcrossIdleStepsWithoutExternalWake() + { + var adapter = new PrototypeSparseSandAdapter(8, 12, new ChunkResidencyConfig(4, 4, Capacity: 16)); + adapter.AddParticle(4, 9, PrototypeParticleType.Steam); + + for (var i = 0; i < 4; i++) + { + adapter.Step(); + } + + adapter.ParticleEntries.Should().Contain(entry => + entry.Value.MotionType == PrototypeParticleType.Steam && + entry.Key.Y <= 6); + } +} diff --git a/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj b/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj new file mode 100644 index 0000000..ce77982 --- /dev/null +++ b/Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj @@ -0,0 +1,23 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/Sand.ChunkPrototype/ChunkActivityTracker.cs b/Sand.ChunkPrototype/ChunkActivityTracker.cs new file mode 100644 index 0000000..068ca20 --- /dev/null +++ b/Sand.ChunkPrototype/ChunkActivityTracker.cs @@ -0,0 +1,31 @@ +namespace Sand.ChunkPrototype; + +internal sealed class ChunkActivityTracker +{ + private readonly List _activeChunks = new(); + private readonly List _sleepingChunks = new(); + + public (IReadOnlyList ActiveChunks, int SleepingChunks) Build(IReadOnlyDictionary pages) + { + _activeChunks.Clear(); + _sleepingChunks.Clear(); + foreach (var (coord, page) in pages) + { + if (page.IsActive || page.HasFieldActivity || page.PendingActiveSteps > 0) + { + _activeChunks.Add(coord); + } + else + { + _sleepingChunks.Add(coord); + } + } + + _activeChunks.Sort(static (left, right) => + { + var yCompare = right.Y.CompareTo(left.Y); + return yCompare != 0 ? yCompare : left.X.CompareTo(right.X); + }); + return (_activeChunks, _sleepingChunks.Count); + } +} diff --git a/Sand.ChunkPrototype/ChunkCellPage.cs b/Sand.ChunkPrototype/ChunkCellPage.cs new file mode 100644 index 0000000..9507d8b --- /dev/null +++ b/Sand.ChunkPrototype/ChunkCellPage.cs @@ -0,0 +1,346 @@ +namespace Sand.ChunkPrototype; + +internal sealed class ChunkCellPage +{ + private readonly PrototypeParticle[] _particles; + private readonly int[] _processedStep; + private readonly int[] _occupiedRowCounts; + private readonly int[] _gasRowCounts; + private readonly int[] _rowRevision; + private readonly int[] _blockedSolidSignature; + private readonly int[] _blockedGasRiseSignature; + private readonly int[] _gasRetrySignature; + private readonly int[] _gasRetryUntilStep; + private readonly int[] _gasRowCooldownSignature; + private readonly int[] _gasRowCooldownUntilStep; + private readonly sbyte[] _driftState; + private readonly float[] _pressureDuration; + private readonly float[] _temperature; + private readonly float[] _burnTime; + private readonly byte[] _burning; + private readonly short[] _sparkTime; + private readonly float[] _lifetime; + private readonly float[] _cellAge; + private readonly float[] _integrity; + + public ChunkCellPage(int width, int height) + { + Width = width; + Height = height; + _particles = new PrototypeParticle[width * height]; + _processedStep = new int[width * height]; + _occupiedRowCounts = new int[height]; + _gasRowCounts = new int[height]; + _rowRevision = new int[height]; + _blockedSolidSignature = new int[width * height]; + _blockedGasRiseSignature = new int[width * height]; + _gasRetrySignature = new int[width * height]; + _gasRetryUntilStep = new int[width * height]; + _gasRowCooldownSignature = new int[height]; + _gasRowCooldownUntilStep = new int[height]; + _driftState = new sbyte[width * height]; + _pressureDuration = new float[width * height]; + _temperature = new float[width * height]; + _burnTime = new float[width * height]; + _burning = new byte[width * height]; + _sparkTime = new short[width * height]; + _lifetime = new float[width * height]; + _cellAge = new float[width * height]; + _integrity = new float[width * height]; + ClearDirtyBands(); + ClearVisualDirty(); + } + + public int Width { get; } + public int Height { get; } + public int OccupancyCount { get; private set; } + public int GasCellCount { get; private set; } + public int DynamicCellCount { get; private set; } + public int RuntimeCellCount { get; private set; } + public int PendingActiveSteps { get; private set; } + public bool IsActive { get; set; } = true; + public bool HasFieldActivity { get; set; } + public int LastTouchedFrame { get; set; } + public int DirtyMinRow { get; private set; } + public int DirtyMaxRow { get; private set; } + public int DirtyMinCol { get; private set; } + public int DirtyMaxCol { get; private set; } + public bool HasDirtyBands { get; private set; } + public int VisualDirtyMinRow { get; private set; } + public int VisualDirtyMaxRow { get; private set; } + public int VisualDirtyMinCol { get; private set; } + public int VisualDirtyMaxCol { get; private set; } + public bool HasVisualDirty { get; private set; } + + public PrototypeParticle this[int localX, int localY] + { + get => _particles[GetIndex(localX, localY)]; + set => _particles[GetIndex(localX, localY)] = value; + } + + public bool IsProcessed(int localX, int localY, int stepToken) => _processedStep[GetIndex(localX, localY)] == stepToken; + + public void MarkProcessed(int localX, int localY, int stepToken) => _processedStep[GetIndex(localX, localY)] = stepToken; + + public bool IsOccupied(int localX, int localY) => _particles[GetIndex(localX, localY)].TypeId != 0; + + public int GetOccupiedRowCount(int localY) => _occupiedRowCounts[localY]; + + public int GetGasRowCount(int localY) => _gasRowCounts[localY]; + + public int GetRowRevision(int localY) => _rowRevision[localY]; + + public int GetBlockedSolidSignature(int localX, int localY) => _blockedSolidSignature[GetIndex(localX, localY)]; + + public void SetBlockedSolidSignature(int localX, int localY, int signature) => _blockedSolidSignature[GetIndex(localX, localY)] = signature; + + public int GetBlockedGasRiseSignature(int localX, int localY) => _blockedGasRiseSignature[GetIndex(localX, localY)]; + + public void SetBlockedGasRiseSignature(int localX, int localY, int signature) => _blockedGasRiseSignature[GetIndex(localX, localY)] = signature; + + public int GetGasRetrySignature(int localX, int localY) => _gasRetrySignature[GetIndex(localX, localY)]; + + public int GetGasRetryUntilStep(int localX, int localY) => _gasRetryUntilStep[GetIndex(localX, localY)]; + + public void SetGasRetryState(int localX, int localY, int signature, int untilStep) + { + var index = GetIndex(localX, localY); + _gasRetrySignature[index] = signature; + _gasRetryUntilStep[index] = untilStep; + } + + public void ClearGasRetryState(int localX, int localY) + { + var index = GetIndex(localX, localY); + _gasRetrySignature[index] = 0; + _gasRetryUntilStep[index] = 0; + } + + public int GetGasRowCooldownSignature(int localY) => _gasRowCooldownSignature[localY]; + + public int GetGasRowCooldownUntilStep(int localY) => _gasRowCooldownUntilStep[localY]; + + public void SetGasRowCooldown(int localY, int signature, int untilStep) + { + _gasRowCooldownSignature[localY] = signature; + _gasRowCooldownUntilStep[localY] = untilStep; + } + + public void ClearGasRowCooldown(int localY) + { + _gasRowCooldownSignature[localY] = 0; + _gasRowCooldownUntilStep[localY] = 0; + } + + public sbyte GetDriftState(int localX, int localY) => _driftState[GetIndex(localX, localY)]; + + public void SetDriftState(int localX, int localY, sbyte driftState) => _driftState[GetIndex(localX, localY)] = driftState; + + public float GetPressureDuration(int localX, int localY) => _pressureDuration[GetIndex(localX, localY)]; + + public void SetPressureDuration(int localX, int localY, float value) => _pressureDuration[GetIndex(localX, localY)] = value; + + public float GetTemperature(int localX, int localY) => _temperature[GetIndex(localX, localY)]; + + public void SetTemperature(int localX, int localY, float value) => _temperature[GetIndex(localX, localY)] = value; + + public float GetBurnTime(int localX, int localY) => _burnTime[GetIndex(localX, localY)]; + + public void SetBurnTime(int localX, int localY, float value) => _burnTime[GetIndex(localX, localY)] = value; + + public byte GetBurning(int localX, int localY) => _burning[GetIndex(localX, localY)]; + + public void SetBurning(int localX, int localY, byte value) => _burning[GetIndex(localX, localY)] = value; + + public short GetSparkTime(int localX, int localY) => _sparkTime[GetIndex(localX, localY)]; + + public void SetSparkTime(int localX, int localY, short value) => _sparkTime[GetIndex(localX, localY)] = value; + + public float GetLifetime(int localX, int localY) => _lifetime[GetIndex(localX, localY)]; + + public void SetLifetime(int localX, int localY, float value) => _lifetime[GetIndex(localX, localY)] = value; + + public float GetCellAge(int localX, int localY) => _cellAge[GetIndex(localX, localY)]; + + public void SetCellAge(int localX, int localY, float value) => _cellAge[GetIndex(localX, localY)] = value; + + public float GetIntegrity(int localX, int localY) => _integrity[GetIndex(localX, localY)]; + + public void SetIntegrity(int localX, int localY, float value) => _integrity[GetIndex(localX, localY)] = value; + + public void SetCell(int localX, int localY, PrototypeParticle particle) + { + var index = GetIndex(localX, localY); + var previous = _particles[index]; + if (previous.TypeId == 0 && particle.TypeId != 0) + { + OccupancyCount++; + _occupiedRowCounts[localY]++; + _rowRevision[localY]++; + } + else if (previous.TypeId != 0 && particle.TypeId == 0) + { + OccupancyCount--; + _occupiedRowCounts[localY]--; + _rowRevision[localY]++; + } + else if (previous.TypeId != particle.TypeId) + { + _rowRevision[localY]++; + } + + if (previous.MotionType == PrototypeParticleType.Steam) + { + GasCellCount--; + _gasRowCounts[localY]--; + } + + if (previous.TypeId != 0 && !previous.IsStatic) + { + DynamicCellCount--; + } + + if (previous.TypeId != 0 && previous.RequiresFullRuntimeStep) + { + RuntimeCellCount--; + } + + _particles[index] = particle; + if (particle.MotionType == PrototypeParticleType.Steam) + { + GasCellCount++; + _gasRowCounts[localY]++; + } + + if (particle.TypeId != 0 && !particle.IsStatic) + { + DynamicCellCount++; + } + + if (particle.TypeId != 0 && particle.RequiresFullRuntimeStep) + { + RuntimeCellCount++; + } + + if (particle.TypeId == 0) + { + _driftState[index] = 0; + _pressureDuration[index] = 0f; + _temperature[index] = 0f; + _burnTime[index] = 0f; + _burning[index] = 0; + _sparkTime[index] = 0; + _lifetime[index] = 0f; + _cellAge[index] = 0f; + _integrity[index] = 0f; + } + + _blockedSolidSignature[index] = 0; + _blockedGasRiseSignature[index] = 0; + _gasRetrySignature[index] = 0; + _gasRetryUntilStep[index] = 0; + _gasRowCooldownSignature[localY] = 0; + _gasRowCooldownUntilStep[localY] = 0; + MarkTouched(localX, localY); + } + + public void MarkTouched(int localX, int localY) + { + MarkDirtyBand(localX, localY); + MarkVisualDirty(localX, localY); + IsActive = true; + PendingActiveSteps = Math.Max(PendingActiveSteps, 1); + } + + public void RequestFutureSteps(int steps) + { + if (steps > PendingActiveSteps) + { + PendingActiveSteps = steps; + } + } + + public void DecayPendingSteps() + { + if (PendingActiveSteps > 0) + { + PendingActiveSteps--; + } + } + + public void MarkDirtyBand(int localX, int localY) + { + if (!HasDirtyBands) + { + DirtyMinRow = DirtyMaxRow = localY; + DirtyMinCol = DirtyMaxCol = localX; + HasDirtyBands = true; + return; + } + + DirtyMinRow = Math.Min(DirtyMinRow, localY); + DirtyMaxRow = Math.Max(DirtyMaxRow, localY); + DirtyMinCol = Math.Min(DirtyMinCol, localX); + DirtyMaxCol = Math.Max(DirtyMaxCol, localX); + } + + public void MarkVisualDirty(int localX, int localY) + { + if (!HasVisualDirty) + { + VisualDirtyMinRow = VisualDirtyMaxRow = localY; + VisualDirtyMinCol = VisualDirtyMaxCol = localX; + HasVisualDirty = true; + return; + } + + VisualDirtyMinRow = Math.Min(VisualDirtyMinRow, localY); + VisualDirtyMaxRow = Math.Max(VisualDirtyMaxRow, localY); + VisualDirtyMinCol = Math.Min(VisualDirtyMinCol, localX); + VisualDirtyMaxCol = Math.Max(VisualDirtyMaxCol, localX); + } + + public void MarkFullVisualDirty() + { + HasVisualDirty = true; + VisualDirtyMinRow = 0; + VisualDirtyMaxRow = Height - 1; + VisualDirtyMinCol = 0; + VisualDirtyMaxCol = Width - 1; + } + + public void ClearDirtyBands() + { + HasDirtyBands = false; + DirtyMinRow = Height; + DirtyMaxRow = -1; + DirtyMinCol = Width; + DirtyMaxCol = -1; + } + + public void ClearVisualDirty() + { + HasVisualDirty = false; + VisualDirtyMinRow = Height; + VisualDirtyMaxRow = -1; + VisualDirtyMinCol = Width; + VisualDirtyMaxCol = -1; + } + + public IEnumerable<(int LocalX, int LocalY, PrototypeParticle Particle)> EnumerateOccupied() + { + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + var particle = _particles[GetIndex(x, y)]; + if (particle.TypeId != 0) + { + yield return (x, y, particle); + } + } + } + } + + private int GetIndex(int localX, int localY) => (localY * Width) + localX; +} diff --git a/Sand.ChunkPrototype/ChunkCoord.cs b/Sand.ChunkPrototype/ChunkCoord.cs new file mode 100644 index 0000000..1582b0b --- /dev/null +++ b/Sand.ChunkPrototype/ChunkCoord.cs @@ -0,0 +1,3 @@ +namespace Sand.ChunkPrototype; + +internal readonly record struct ChunkCoord(int X, int Y); diff --git a/Sand.ChunkPrototype/ChunkFieldPage.cs b/Sand.ChunkPrototype/ChunkFieldPage.cs new file mode 100644 index 0000000..283a1be --- /dev/null +++ b/Sand.ChunkPrototype/ChunkFieldPage.cs @@ -0,0 +1,96 @@ +namespace Sand.ChunkPrototype; + +internal sealed class ChunkFieldPage +{ + public ChunkFieldPage(int width, int height) + { + Width = width; + Height = height; + WindX = new float[width * height]; + WindY = new float[width * height]; + ForceX = new float[width * height]; + ForceY = new float[width * height]; + Pressure = new float[width * height]; + } + + public int Width { get; } + public int Height { get; } + public float[] WindX { get; } + public float[] WindY { get; } + public float[] ForceX { get; } + public float[] ForceY { get; } + public float[] Pressure { get; } + public int ActiveCellCount { get; private set; } + public int LastDecayedFrame { get; set; } + + public bool TryGetCell(int localX, int localY, out FieldCellData cell) + { + var index = GetIndex(localX, localY); + cell = new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index]); + return !cell.IsEffectivelyZero; + } + + public FieldCellData GetCell(int localX, int localY) + { + var index = GetIndex(localX, localY); + return new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index]); + } + + public void SetCell(int localX, int localY, FieldCellData value) + { + var index = GetIndex(localX, localY); + var wasZero = IsEffectivelyZero(index); + var nowZero = value.IsEffectivelyZero; + WindX[index] = value.WindX; + WindY[index] = value.WindY; + ForceX[index] = value.ForceX; + ForceY[index] = value.ForceY; + Pressure[index] = value.Pressure; + if (wasZero && !nowZero) + { + ActiveCellCount++; + } + else if (!wasZero && nowZero) + { + ActiveCellCount--; + } + } + + public bool IsEmpty() => ActiveCellCount == 0; + + public IEnumerable<(int LocalX, int LocalY, FieldCellData Cell)> EnumerateActiveCells() + { + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + var index = GetIndex(x, y); + if (IsEffectivelyZero(index)) + { + continue; + } + + yield return (x, y, new FieldCellData(WindX[index], WindY[index], ForceX[index], ForceY[index], Pressure[index])); + } + } + } + + private bool IsEffectivelyZero(int index) => + MathF.Abs(WindX[index]) < 0.01f && + MathF.Abs(WindY[index]) < 0.01f && + MathF.Abs(ForceX[index]) < 0.01f && + MathF.Abs(ForceY[index]) < 0.01f && + MathF.Abs(Pressure[index]) < 0.01f; + + private int GetIndex(int localX, int localY) => (localY * Width) + localX; +} + +internal readonly record struct FieldCellData(float WindX, float WindY, float ForceX, float ForceY, float Pressure) +{ + public bool IsEffectivelyZero => + MathF.Abs(WindX) < 0.01f && + MathF.Abs(WindY) < 0.01f && + MathF.Abs(ForceX) < 0.01f && + MathF.Abs(ForceY) < 0.01f && + MathF.Abs(Pressure) < 0.01f; +} diff --git a/Sand.ChunkPrototype/ChunkResidencyConfig.cs b/Sand.ChunkPrototype/ChunkResidencyConfig.cs new file mode 100644 index 0000000..311f0b0 --- /dev/null +++ b/Sand.ChunkPrototype/ChunkResidencyConfig.cs @@ -0,0 +1,9 @@ +namespace Sand.ChunkPrototype; + +public sealed record ChunkResidencyConfig( + int ChunkWidth, + int ChunkHeight, + int Capacity = 4096) +{ + public static ChunkResidencyConfig Default { get; } = new(32, 32); +} diff --git a/Sand.ChunkPrototype/ChunkStepScheduler.cs b/Sand.ChunkPrototype/ChunkStepScheduler.cs new file mode 100644 index 0000000..a16870a --- /dev/null +++ b/Sand.ChunkPrototype/ChunkStepScheduler.cs @@ -0,0 +1,9 @@ +namespace Sand.ChunkPrototype; + +internal sealed class ChunkStepScheduler +{ + private readonly ChunkActivityTracker _activityTracker = new(); + + public (IReadOnlyList ActiveChunks, int SleepingChunks) BuildSchedule(IReadOnlyDictionary pages) => + _activityTracker.Build(pages); +} diff --git a/Sand.ChunkPrototype/ChunkStepStats.cs b/Sand.ChunkPrototype/ChunkStepStats.cs new file mode 100644 index 0000000..c606fd3 --- /dev/null +++ b/Sand.ChunkPrototype/ChunkStepStats.cs @@ -0,0 +1,27 @@ +namespace Sand.ChunkPrototype; + +public readonly record struct ChunkStepStats( + int SteppedChunks, + int SleepingChunks, + int FieldPages, + int MoveAttempts, + int VerticalMoveAttempts, + int DiagonalMoveAttempts, + int LateralMoveAttempts, + int SuccessfulMoves, + int SwapAttempts, + int StalledMovableCells, + int MovementOnlyFastPathCount, + int FullRuntimeStepCount, + int FullRuntimeSolidCount, + int FullRuntimeLiquidCount, + int FullRuntimeGasCount, + int MovedParticles, + int SwappedParticles, + int VisualDirtyPages, + long FrameBuildBytesTouched, + long ActivationTimeMicroseconds, + long MovementTimeMicroseconds, + long RuntimeTimeMicroseconds, + long FieldDecayTimeMicroseconds, + long RenderTimeMicroseconds); diff --git a/Sand.ChunkPrototype/ChunkVisualTracker.cs b/Sand.ChunkPrototype/ChunkVisualTracker.cs new file mode 100644 index 0000000..18694b6 --- /dev/null +++ b/Sand.ChunkPrototype/ChunkVisualTracker.cs @@ -0,0 +1,160 @@ +namespace Sand.ChunkPrototype; + +internal static class ChunkVisualTracker +{ + public static long RenderDirtyPages( + byte[] destination, + int worldWidth, + IReadOnlyDictionary cellPages, + IReadOnlyDictionary fieldPages, + bool enableWindVisuals, + bool enablePressureVisuals) + { + long bytesTouched = 0; + foreach (var (coord, page) in cellPages) + { + if (!page.HasVisualDirty) + { + continue; + } + + bytesTouched += RenderPage(destination, worldWidth, coord, page, fieldPages.GetValueOrDefault(coord), enableWindVisuals, enablePressureVisuals); + page.ClearVisualDirty(); + } + + foreach (var (coord, fieldPage) in fieldPages) + { + if (cellPages.ContainsKey(coord)) + { + continue; + } + + bytesTouched += RenderFieldOnlyPage(destination, worldWidth, coord, fieldPage, enableWindVisuals, enablePressureVisuals); + } + + return bytesTouched; + } + + private static long RenderPage( + byte[] destination, + int worldWidth, + ChunkCoord coord, + ChunkCellPage page, + ChunkFieldPage? fieldPage, + bool enableWindVisuals, + bool enablePressureVisuals) + { + long bytesTouched = 0; + for (var localY = page.VisualDirtyMinRow; localY <= page.VisualDirtyMaxRow; localY++) + { + for (var localX = page.VisualDirtyMinCol; localX <= page.VisualDirtyMaxCol; localX++) + { + var particle = page[localX, localY]; + var worldX = (coord.X * page.Width) + localX; + var worldY = (coord.Y * page.Height) + localY; + var rgbaIndex = ((worldY * worldWidth) + worldX) * 4; + if (particle.TypeId != 0) + { + destination[rgbaIndex] = particle.R; + destination[rgbaIndex + 1] = particle.G; + destination[rgbaIndex + 2] = particle.B; + destination[rgbaIndex + 3] = 255; + bytesTouched += 4; + continue; + } + + var color = ResolveFieldColor(fieldPage, localX, localY, enableWindVisuals, enablePressureVisuals); + destination[rgbaIndex] = color.R; + destination[rgbaIndex + 1] = color.G; + destination[rgbaIndex + 2] = color.B; + destination[rgbaIndex + 3] = 255; + bytesTouched += 4; + } + } + + return bytesTouched; + } + + private static long RenderFieldOnlyPage( + byte[] destination, + int worldWidth, + ChunkCoord coord, + ChunkFieldPage page, + bool enableWindVisuals, + bool enablePressureVisuals) + { + if (!enableWindVisuals && !enablePressureVisuals) + { + return 0; + } + + long bytesTouched = 0; + foreach (var (localX, localY, cell) in page.EnumerateActiveCells()) + { + var color = ResolveFieldColor(cell, enableWindVisuals, enablePressureVisuals); + var worldX = (coord.X * page.Width) + localX; + var worldY = (coord.Y * page.Height) + localY; + var rgbaIndex = ((worldY * worldWidth) + worldX) * 4; + destination[rgbaIndex] = color.R; + destination[rgbaIndex + 1] = color.G; + destination[rgbaIndex + 2] = color.B; + destination[rgbaIndex + 3] = 255; + bytesTouched += 4; + } + + return bytesTouched; + } + + private static (byte R, byte G, byte B) ResolveFieldColor(ChunkFieldPage? fieldPage, int localX, int localY, bool enableWindVisuals, bool enablePressureVisuals) + { + if (fieldPage is null || !fieldPage.TryGetCell(localX, localY, out var cell)) + { + return (0, 0, 0); + } + + return ResolveFieldColor(cell, enableWindVisuals, enablePressureVisuals); + } + + private static (byte R, byte G, byte B) ResolveFieldColor(FieldCellData cell, bool enableWindVisuals, bool enablePressureVisuals) + { + byte r = 0; + byte g = 0; + byte b = 0; + if (enablePressureVisuals) + { + var pressure = MathF.Abs(cell.Pressure); + if (pressure > 0.08f) + { + var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140); + if (cell.Pressure >= 0f) + { + r = (byte)Math.Max((int)r, 26 + tint); + g = (byte)Math.Max((int)g, 18 + (tint / 3)); + b = (byte)Math.Max((int)b, 24); + } + else + { + r = (byte)Math.Max((int)r, 18); + g = (byte)Math.Max((int)g, 24 + (tint / 4)); + b = (byte)Math.Max((int)b, 30 + tint); + } + } + } + + if (enableWindVisuals) + { + var totalFieldX = cell.WindX + cell.ForceX; + var totalFieldY = cell.WindY + cell.ForceY; + var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY)); + if (windStrength > 0.08f) + { + var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120); + r = (byte)Math.Max((int)r, 16 + (tint / 3)); + g = (byte)Math.Max((int)g, 24 + (tint / 2)); + b = (byte)Math.Max((int)b, 32 + tint); + } + } + + return (r, g, b); + } +} diff --git a/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs b/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs new file mode 100644 index 0000000..b66fa8e --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs @@ -0,0 +1,254 @@ +using AdvChkSys.Chunk; +using AdvChkSys.Manager; + +namespace Sand.ChunkPrototype; + +public sealed class PrototypeChunkResidencyWorld +{ + private readonly ChunkManager2D _manager; + private readonly Dictionary<(int ChunkX, int ChunkY), int> _occupancyCounts = new(); + private readonly HashSet<(int ChunkX, int ChunkY)> _dirtyChunks = new(); + private readonly ChunkResidencyConfig _config; + + public PrototypeChunkResidencyWorld(ChunkResidencyConfig? config = null) + { + _config = config ?? ChunkResidencyConfig.Default; + _manager = new ChunkManager2D( + capacity: _config.Capacity, + chunkWidth: _config.ChunkWidth, + chunkHeight: _config.ChunkHeight); + } + + public int LoadedChunkCount => _manager.GetAllChunks().Count(); + public int ChunkWidth => _config.ChunkWidth; + public int ChunkHeight => _config.ChunkHeight; + + public int ActiveChunkCount => _occupancyCounts.Count; + + public int DirtyChunkCount => _dirtyChunks.Count; + + public long EstimatedLoadedBytes => (long)LoadedChunkCount * _config.ChunkWidth * _config.ChunkHeight * sizeof(byte); + + public IReadOnlyCollection<(int ChunkX, int ChunkY)> ActiveChunks => _occupancyCounts.Keys.ToArray(); + + public IReadOnlyCollection<(int ChunkX, int ChunkY)> DirtyChunks => _dirtyChunks.ToArray(); + + public void SetOccupied(int cellX, int cellY, byte value = 1) + { + var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out var key); + if (chunk[localX, localY] == 0) + { + _occupancyCounts[key] = _occupancyCounts.TryGetValue(key, out var count) ? count + 1 : 1; + } + + chunk[localX, localY] = value; + _dirtyChunks.Add(key); + } + + public void ClearOccupied(int cellX, int cellY) + { + var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out var key); + if (chunk[localX, localY] == 0) + { + return; + } + + chunk[localX, localY] = 0; + if (!_occupancyCounts.TryGetValue(key, out var count)) + { + return; + } + + if (count <= 1) + { + _occupancyCounts.Remove(key); + _dirtyChunks.Add(key); + return; + } + + _occupancyCounts[key] = count - 1; + _dirtyChunks.Add(key); + } + + public bool IsOccupied(int cellX, int cellY) + { + var chunk = GetChunk(cellX, cellY, out var localX, out var localY, out _); + return chunk[localX, localY] != 0; + } + + public bool IsChunkLoaded(int chunkX, int chunkY) => _manager.GetChunk(chunkX, chunkY) is not null; + + public int GetChunkOccupancyCount(int chunkX, int chunkY) => + _occupancyCounts.TryGetValue((chunkX, chunkY), out var count) ? count : 0; + + public void ClearDirtyChunks() => _dirtyChunks.Clear(); + + public bool MoveOccupied(int fromCellX, int fromCellY, int toCellX, int toCellY, byte value = 1) + { + var sourceChunk = GetChunk(fromCellX, fromCellY, out var sourceLocalX, out var sourceLocalY, out var sourceKey); + if (sourceChunk[sourceLocalX, sourceLocalY] == 0) + { + return false; + } + + if (fromCellX == toCellX && fromCellY == toCellY) + { + sourceChunk[sourceLocalX, sourceLocalY] = value; + _dirtyChunks.Add(sourceKey); + return true; + } + + var destinationChunk = GetChunk(toCellX, toCellY, out var destinationLocalX, out var destinationLocalY, out var destinationKey); + if (destinationChunk[destinationLocalX, destinationLocalY] != 0) + { + return false; + } + + sourceChunk[sourceLocalX, sourceLocalY] = 0; + destinationChunk[destinationLocalX, destinationLocalY] = value; + + DecrementChunkOccupancy(sourceKey); + _occupancyCounts[destinationKey] = _occupancyCounts.TryGetValue(destinationKey, out var destinationCount) ? destinationCount + 1 : 1; + + _dirtyChunks.Add(sourceKey); + _dirtyChunks.Add(destinationKey); + return true; + } + + public bool MoveOccupiedWithinChunk(int chunkX, int chunkY, int fromLocalX, int fromLocalY, int toLocalX, int toLocalY, byte value = 1) + { + var chunk = _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight); + if (chunk[fromLocalX, fromLocalY] == 0 || chunk[toLocalX, toLocalY] != 0) + { + return false; + } + + chunk[fromLocalX, fromLocalY] = 0; + chunk[toLocalX, toLocalY] = value; + _dirtyChunks.Add((chunkX, chunkY)); + return true; + } + + public bool SwapOccupied(int firstCellX, int firstCellY, int secondCellX, int secondCellY, byte firstValue, byte secondValue) + { + var firstChunk = GetChunk(firstCellX, firstCellY, out var firstLocalX, out var firstLocalY, out var firstKey); + var secondChunk = GetChunk(secondCellX, secondCellY, out var secondLocalX, out var secondLocalY, out var secondKey); + if (firstChunk[firstLocalX, firstLocalY] == 0 || secondChunk[secondLocalX, secondLocalY] == 0) + { + return false; + } + + firstChunk[firstLocalX, firstLocalY] = secondValue; + secondChunk[secondLocalX, secondLocalY] = firstValue; + _dirtyChunks.Add(firstKey); + _dirtyChunks.Add(secondKey); + return true; + } + + public bool SwapOccupiedWithinChunk(int chunkX, int chunkY, int firstLocalX, int firstLocalY, int secondLocalX, int secondLocalY, byte firstValue, byte secondValue) + { + var chunk = _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight); + if (chunk[firstLocalX, firstLocalY] == 0 || chunk[secondLocalX, secondLocalY] == 0) + { + return false; + } + + chunk[firstLocalX, firstLocalY] = secondValue; + chunk[secondLocalX, secondLocalY] = firstValue; + _dirtyChunks.Add((chunkX, chunkY)); + return true; + } + + public int UnloadEmptyChunks() + { + var unloaded = 0; + foreach (var chunk in _manager.GetAllChunks().ToArray()) + { + var key = (chunk.X, chunk.Y); + if (_occupancyCounts.ContainsKey(key)) + { + continue; + } + + if (_manager.UnloadChunk(chunk.X, chunk.Y)) + { + unloaded++; + } + } + + return unloaded; + } + + public int UnloadInactiveChunks(int marginChunks = 0) + { + if (_occupancyCounts.Count == 0) + { + return UnloadEmptyChunks(); + } + + var keep = new HashSet<(int ChunkX, int ChunkY)>(); + foreach (var (chunkX, chunkY) in _occupancyCounts.Keys) + { + for (var offsetX = -marginChunks; offsetX <= marginChunks; offsetX++) + { + for (var offsetY = -marginChunks; offsetY <= marginChunks; offsetY++) + { + keep.Add((chunkX + offsetX, chunkY + offsetY)); + } + } + } + + var unloaded = 0; + foreach (var chunk in _manager.GetAllChunks().ToArray()) + { + var key = (chunk.X, chunk.Y); + if (keep.Contains(key)) + { + continue; + } + + if (_manager.UnloadChunk(chunk.X, chunk.Y)) + { + unloaded++; + } + } + + return unloaded; + } + + private void DecrementChunkOccupancy((int ChunkX, int ChunkY) key) + { + if (!_occupancyCounts.TryGetValue(key, out var count)) + { + return; + } + + if (count <= 1) + { + _occupancyCounts.Remove(key); + return; + } + + _occupancyCounts[key] = count - 1; + } + + private Chunk2D GetChunk(int cellX, int cellY, out int localX, out int localY, out (int ChunkX, int ChunkY) key) + { + var chunkX = Math.DivRem(cellX, _config.ChunkWidth, out localX); + var chunkY = Math.DivRem(cellY, _config.ChunkHeight, out localY); + if (localX < 0) + { + localX += _config.ChunkWidth; + chunkX--; + } + + if (localY < 0) + { + localY += _config.ChunkHeight; + chunkY--; + } + + key = (chunkX, chunkY); + return _manager.LoadOrCreateChunk(chunkX, chunkY, _config.ChunkWidth, _config.ChunkHeight); + } +} diff --git a/Sand.ChunkPrototype/PrototypeParticle.cs b/Sand.ChunkPrototype/PrototypeParticle.cs new file mode 100644 index 0000000..c1492aa --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeParticle.cs @@ -0,0 +1,122 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +[Flags] +public enum PrototypeParticleFlags : ushort +{ + None = 0, + WaterLike = 1 << 0, + FireLike = 1 << 1, + Acidic = 1 << 2, + HotSource = 1 << 3, +} + +public readonly record struct PrototypeParticle( + ushort TypeId, + string Id, + PrototypeParticleType MotionType, + ParticleKind Kind, + ParticleBehaviorKind BehaviorKind, + byte R, + byte G, + byte B, + float Mass, + float Velocity, + float Friction, + float Viscosity, + bool IsStatic = false, + PrototypeParticleFlags Flags = PrototypeParticleFlags.None, + bool IsMolten = false, + ushort HydrateTargetTypeId = 0, + ushort MeltTypeId = 0, + ushort EvaporateTypeId = 0, + ushort SolidifyTypeId = 0, + ushort FreezeTypeId = 0, + ushort BrokenTypeId = 0, + ushort ProduceTypeId = 0, + ushort ProducesOnDeathTypeId = 0, + float PressureThreshold = 0f, + float PressureResistance = 0f, + float PressureTolerance = 0f, + short PressureThresholdDuration = 0, + float PressureResponse = 1f, + float ForceResponseMultiplier = 1f, + float LateralFlowMultiplier = 1f, + float DiagonalFlowMultiplier = 1f, + float UpwardBias = 0f, + float SideDriftBias = 0f, + float InitialTemperature = 22f, + float MeltTemperature = float.PositiveInfinity, + float EvaporateTemperature = float.PositiveInfinity, + float SolidifyTemperature = float.NegativeInfinity, + float FreezeTemperature = float.NegativeInfinity, + float BurnDuration = 0f, + float BurnTemperature = 0f, + float BurnRate = 1f, + bool BurningInit = false, + float DefaultLifetime = 0f, + float HeatEmission = 0f, + float SmokeSpawnChance = 0f, + float EmberSpawnChance = 0f, + float Hardness = 0.5f, + float Durability = 100f, + float Flamability = 0f, + float Conductivity = 0f, + bool Conductive = false, + float AmbientCoolingMultiplier = 1f, + float NeighborHeatTransferMultiplier = 1f, + float PhaseTransitionHysteresis = 0f) +{ + public bool IsEmpty => TypeId == 0 || MotionType == PrototypeParticleType.Empty; + + public bool HasFlag(PrototypeParticleFlags flag) => (Flags & flag) != 0; + + public bool HasLifetimeBehavior => DefaultLifetime > 0f; + + public bool HasBurnBehavior => BurningInit || BurnTemperature > 0f || BurnDuration > 0f || Flamability > 0f; + + public bool HasEmissionBehavior => HeatEmission > 0f || SmokeSpawnChance > 0f || EmberSpawnChance > 0f || ProduceTypeId != 0 || ProducesOnDeathTypeId != 0; + + public bool HasSpecialBehavior => BehaviorKind != ParticleBehaviorKind.None; + + public bool HasReactionBehavior => + HydrateTargetTypeId != 0 || + HasFlag(PrototypeParticleFlags.WaterLike) || + HasFlag(PrototypeParticleFlags.FireLike) || + HasFlag(PrototypeParticleFlags.Acidic) || + HasFlag(PrototypeParticleFlags.HotSource); + + public bool HasPhaseBehavior => + (MeltTypeId != 0 && !float.IsPositiveInfinity(MeltTemperature)) || + (EvaporateTypeId != 0 && !float.IsPositiveInfinity(EvaporateTemperature)) || + (SolidifyTypeId != 0 && !float.IsNegativeInfinity(SolidifyTemperature)) || + (FreezeTypeId != 0 && !float.IsNegativeInfinity(FreezeTemperature)) || + (MotionType == PrototypeParticleType.Steam && SolidifyTypeId != 0); + + public bool HasPressureBehavior => + BrokenTypeId != 0 || + PressureThreshold > 0f || + PressureResistance > 0f || + PressureTolerance > 0f || + PressureThresholdDuration > 0; + + public bool HasThermalBehavior => + HasBurnBehavior || + HasEmissionBehavior || + HasPhaseBehavior || + HasSpecialBehavior || + Conductivity > 0f || + AmbientCoolingMultiplier != 1f || + NeighborHeatTransferMultiplier != 1f; + + public bool RequiresFullRuntimeStep => + HasLifetimeBehavior || + HasBurnBehavior || + HasEmissionBehavior || + HasSpecialBehavior || + HasReactionBehavior || + HasPhaseBehavior || + HasPressureBehavior || + HasThermalBehavior; +} diff --git a/Sand.ChunkPrototype/PrototypeParticleType.cs b/Sand.ChunkPrototype/PrototypeParticleType.cs new file mode 100644 index 0000000..a375e2d --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeParticleType.cs @@ -0,0 +1,10 @@ +namespace Sand.ChunkPrototype; + +public enum PrototypeParticleType : byte +{ + Empty = 0, + Sand = 1, + Water = 2, + Steam = 3, + Wall = 4, +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs new file mode 100644 index 0000000..3cef8bb --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs @@ -0,0 +1,401 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + public bool AddParticle(int x, int y, PrototypeParticle particle) + { + if (!InBounds(x, y) || particle.IsEmpty) + { + return false; + } + + RegisterParticleProfile(particle); + + var coord = GetChunkCoord(x, y); + var (localX, localY) = GetLocalCoord(x, y); + var page = GetOrCreateCellPage(coord); + if (page.IsOccupied(localX, localY)) + { + if (particle.MotionType == PrototypeParticleType.Wall) + { + page.SetCell(localX, localY, particle); + page.SetDriftState(localX, localY, 0); + InitializeRuntimeState(page, localX, localY, particle); + page.LastTouchedFrame = _stepCounter; + MarkChunkActive(coord, page); + World.SetOccupied(x, y, (byte)particle.MotionType); + return true; + } + + return false; + } + + page.SetCell(localX, localY, particle); + page.SetDriftState(localX, localY, GetDefaultDriftDirection(x, y)); + InitializeRuntimeState(page, localX, localY, particle); + page.LastTouchedFrame = _stepCounter; + _particleCount++; + if (_fieldPages.TryGetValue(coord, out var fieldPage)) + { + fieldPage.SetCell(localX, localY, default); + page.HasFieldActivity = !fieldPage.IsEmpty(); + } + + World.SetOccupied(x, y, (byte)particle.MotionType); + MarkChunkActive(coord, page); + WakeNeighborsForBorderTouch(coord, localX, localY); + return true; + } + + public bool HasParticle(int x, int y) => TryGetParticle(x, y, out _); + + public bool TryGetParticle(int x, int y, out PrototypeParticle particle) + { + particle = default; + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return false; + } + + particle = page[localX, localY]; + return particle.TypeId != 0; + } + + public PrototypeParticleType GetParticleTypeAt(int x, int y) => TryGetParticle(x, y, out var particle) ? particle.MotionType : PrototypeParticleType.Empty; + + public ushort GetTypeIdAt(int x, int y) => TryGetParticle(x, y, out var particle) ? particle.TypeId : (ushort)0; + + public float GetTemperatureAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return _ambientTemperature; + } + + return page.GetTemperature(localX, localY); + } + + public bool RemoveParticle(int x, int y) + { + if (!TryGetCellPage(x, y, out var coord, out var page, out var localX, out var localY)) + { + return false; + } + + var particle = page[localX, localY]; + if (particle.TypeId == 0) + { + return false; + } + + page.SetCell(localX, localY, default); + page.SetDriftState(localX, localY, 0); + page.LastTouchedFrame = _stepCounter; + _particleCount--; + World.ClearOccupied(x, y); + MarkChunkActive(coord, page); + WakeNeighborsForBorderTouch(coord, localX, localY); + RemoveEmptyCellPageIfUnused(coord, page); + return true; + } + + private bool MoveParticle(int fromX, int fromY, int toX, int toY) + { + if (!TryGetCellPage(fromX, fromY, out var sourceCoord, out var sourcePage, out var sourceLocalX, out var sourceLocalY)) + { + return false; + } + + var particle = sourcePage[sourceLocalX, sourceLocalY]; + if (particle.TypeId == 0) + { + return false; + } + + var destinationCoord = GetChunkCoord(toX, toY); + var (destinationLocalX, destinationLocalY) = GetLocalCoord(toX, toY); + var destinationPage = GetOrCreateCellPage(destinationCoord); + if (destinationPage.IsOccupied(destinationLocalX, destinationLocalY)) + { + return false; + } + + var driftState = sourcePage.GetDriftState(sourceLocalX, sourceLocalY); + var pressureDuration = sourcePage.GetPressureDuration(sourceLocalX, sourceLocalY); + var temperature = sourcePage.GetTemperature(sourceLocalX, sourceLocalY); + var burnTime = sourcePage.GetBurnTime(sourceLocalX, sourceLocalY); + var burning = sourcePage.GetBurning(sourceLocalX, sourceLocalY); + var sparkTime = sourcePage.GetSparkTime(sourceLocalX, sourceLocalY); + var lifetime = sourcePage.GetLifetime(sourceLocalX, sourceLocalY); + var cellAge = sourcePage.GetCellAge(sourceLocalX, sourceLocalY); + var integrity = sourcePage.GetIntegrity(sourceLocalX, sourceLocalY); + sourcePage.SetCell(sourceLocalX, sourceLocalY, default); + sourcePage.SetDriftState(sourceLocalX, sourceLocalY, 0); + sourcePage.SetPressureDuration(sourceLocalX, sourceLocalY, 0f); + destinationPage.SetCell(destinationLocalX, destinationLocalY, particle); + destinationPage.SetDriftState(destinationLocalX, destinationLocalY, ResolveDriftAfterMove(fromX, toX, driftState)); + destinationPage.SetPressureDuration(destinationLocalX, destinationLocalY, pressureDuration); + destinationPage.SetTemperature(destinationLocalX, destinationLocalY, temperature); + destinationPage.SetBurnTime(destinationLocalX, destinationLocalY, burnTime); + destinationPage.SetBurning(destinationLocalX, destinationLocalY, burning); + destinationPage.SetSparkTime(destinationLocalX, destinationLocalY, sparkTime); + destinationPage.SetLifetime(destinationLocalX, destinationLocalY, lifetime); + destinationPage.SetCellAge(destinationLocalX, destinationLocalY, cellAge); + destinationPage.SetIntegrity(destinationLocalX, destinationLocalY, integrity); + destinationPage.MarkProcessed(destinationLocalX, destinationLocalY, _stepCounter); + sourcePage.LastTouchedFrame = _stepCounter; + destinationPage.LastTouchedFrame = _stepCounter; + MarkChunkActive(sourceCoord, sourcePage); + MarkChunkActive(destinationCoord, destinationPage); + World.MoveOccupied(fromX, fromY, toX, toY, (byte)particle.MotionType); + WakeNeighborsForBorderTouch(sourceCoord, sourceLocalX, sourceLocalY); + WakeNeighborsForBorderTouch(destinationCoord, destinationLocalX, destinationLocalY); + RemoveEmptyCellPageIfUnused(sourceCoord, sourcePage); + _movedParticles++; + return true; + } + + private bool MoveParticleWithinPage(ChunkCoord coord, ChunkCellPage page, int fromLocalX, int fromLocalY, int toLocalX, int toLocalY, int fromX, int fromY, int toX, int toY) + { + var particle = page[fromLocalX, fromLocalY]; + if (particle.TypeId == 0 || page.IsOccupied(toLocalX, toLocalY)) + { + return false; + } + + var driftState = page.GetDriftState(fromLocalX, fromLocalY); + var pressureDuration = page.GetPressureDuration(fromLocalX, fromLocalY); + var temperature = page.GetTemperature(fromLocalX, fromLocalY); + var burnTime = page.GetBurnTime(fromLocalX, fromLocalY); + var burning = page.GetBurning(fromLocalX, fromLocalY); + var sparkTime = page.GetSparkTime(fromLocalX, fromLocalY); + var lifetime = page.GetLifetime(fromLocalX, fromLocalY); + var cellAge = page.GetCellAge(fromLocalX, fromLocalY); + var integrity = page.GetIntegrity(fromLocalX, fromLocalY); + page.SetCell(fromLocalX, fromLocalY, default); + page.SetDriftState(fromLocalX, fromLocalY, 0); + page.SetPressureDuration(fromLocalX, fromLocalY, 0f); + page.SetCell(toLocalX, toLocalY, particle); + page.SetDriftState(toLocalX, toLocalY, ResolveDriftAfterMove(fromX, toX, driftState)); + page.SetPressureDuration(toLocalX, toLocalY, pressureDuration); + page.SetTemperature(toLocalX, toLocalY, temperature); + page.SetBurnTime(toLocalX, toLocalY, burnTime); + page.SetBurning(toLocalX, toLocalY, burning); + page.SetSparkTime(toLocalX, toLocalY, sparkTime); + page.SetLifetime(toLocalX, toLocalY, lifetime); + page.SetCellAge(toLocalX, toLocalY, cellAge); + page.SetIntegrity(toLocalX, toLocalY, integrity); + page.MarkProcessed(toLocalX, toLocalY, _stepCounter); + page.LastTouchedFrame = _stepCounter; + MarkChunkActive(coord, page); + World.MoveOccupiedWithinChunk(coord.X, coord.Y, fromLocalX, fromLocalY, toLocalX, toLocalY, (byte)particle.MotionType); + WakeNeighborsForBorderTouch(coord, fromLocalX, fromLocalY); + WakeNeighborsForBorderTouch(coord, toLocalX, toLocalY); + _movedParticles++; + return true; + } + + private bool SwapParticles(int firstX, int firstY, int secondX, int secondY) + { + if (!TryGetCellPage(firstX, firstY, out var firstCoord, out var firstPage, out var firstLocalX, out var firstLocalY) || + !TryGetCellPage(secondX, secondY, out var secondCoord, out var secondPage, out var secondLocalX, out var secondLocalY)) + { + return false; + } + + var firstParticle = firstPage[firstLocalX, firstLocalY]; + var secondParticle = secondPage[secondLocalX, secondLocalY]; + if (firstParticle.TypeId == 0 || secondParticle.TypeId == 0) + { + return false; + } + + var firstDrift = firstPage.GetDriftState(firstLocalX, firstLocalY); + var secondDrift = secondPage.GetDriftState(secondLocalX, secondLocalY); + var firstPressureDuration = firstPage.GetPressureDuration(firstLocalX, firstLocalY); + var secondPressureDuration = secondPage.GetPressureDuration(secondLocalX, secondLocalY); + var firstTemperature = firstPage.GetTemperature(firstLocalX, firstLocalY); + var secondTemperature = secondPage.GetTemperature(secondLocalX, secondLocalY); + var firstBurnTime = firstPage.GetBurnTime(firstLocalX, firstLocalY); + var secondBurnTime = secondPage.GetBurnTime(secondLocalX, secondLocalY); + var firstBurning = firstPage.GetBurning(firstLocalX, firstLocalY); + var secondBurning = secondPage.GetBurning(secondLocalX, secondLocalY); + var firstSparkTime = firstPage.GetSparkTime(firstLocalX, firstLocalY); + var secondSparkTime = secondPage.GetSparkTime(secondLocalX, secondLocalY); + var firstLifetime = firstPage.GetLifetime(firstLocalX, firstLocalY); + var secondLifetime = secondPage.GetLifetime(secondLocalX, secondLocalY); + var firstAge = firstPage.GetCellAge(firstLocalX, firstLocalY); + var secondAge = secondPage.GetCellAge(secondLocalX, secondLocalY); + var firstIntegrity = firstPage.GetIntegrity(firstLocalX, firstLocalY); + var secondIntegrity = secondPage.GetIntegrity(secondLocalX, secondLocalY); + firstPage.SetCell(firstLocalX, firstLocalY, secondParticle); + firstPage.SetDriftState(firstLocalX, firstLocalY, ResolveDriftAfterMove(secondX, firstX, secondDrift)); + firstPage.SetPressureDuration(firstLocalX, firstLocalY, secondPressureDuration); + firstPage.SetTemperature(firstLocalX, firstLocalY, secondTemperature); + firstPage.SetBurnTime(firstLocalX, firstLocalY, secondBurnTime); + firstPage.SetBurning(firstLocalX, firstLocalY, secondBurning); + firstPage.SetSparkTime(firstLocalX, firstLocalY, secondSparkTime); + firstPage.SetLifetime(firstLocalX, firstLocalY, secondLifetime); + firstPage.SetCellAge(firstLocalX, firstLocalY, secondAge); + firstPage.SetIntegrity(firstLocalX, firstLocalY, secondIntegrity); + secondPage.SetCell(secondLocalX, secondLocalY, firstParticle); + secondPage.SetDriftState(secondLocalX, secondLocalY, ResolveDriftAfterMove(firstX, secondX, firstDrift)); + secondPage.SetPressureDuration(secondLocalX, secondLocalY, firstPressureDuration); + secondPage.SetTemperature(secondLocalX, secondLocalY, firstTemperature); + secondPage.SetBurnTime(secondLocalX, secondLocalY, firstBurnTime); + secondPage.SetBurning(secondLocalX, secondLocalY, firstBurning); + secondPage.SetSparkTime(secondLocalX, secondLocalY, firstSparkTime); + secondPage.SetLifetime(secondLocalX, secondLocalY, firstLifetime); + secondPage.SetCellAge(secondLocalX, secondLocalY, firstAge); + secondPage.SetIntegrity(secondLocalX, secondLocalY, firstIntegrity); + firstPage.MarkProcessed(firstLocalX, firstLocalY, _stepCounter); + secondPage.MarkProcessed(secondLocalX, secondLocalY, _stepCounter); + firstPage.LastTouchedFrame = _stepCounter; + secondPage.LastTouchedFrame = _stepCounter; + MarkChunkActive(firstCoord, firstPage); + MarkChunkActive(secondCoord, secondPage); + if (firstCoord == secondCoord) + { + World.SwapOccupiedWithinChunk(firstCoord.X, firstCoord.Y, firstLocalX, firstLocalY, secondLocalX, secondLocalY, (byte)firstParticle.MotionType, (byte)secondParticle.MotionType); + } + else + { + World.SwapOccupied(firstX, firstY, secondX, secondY, (byte)firstParticle.MotionType, (byte)secondParticle.MotionType); + } + WakeNeighborsForBorderTouch(firstCoord, firstLocalX, firstLocalY); + WakeNeighborsForBorderTouch(secondCoord, secondLocalX, secondLocalY); + _swappedParticles++; + return true; + } + + private bool ReplaceParticle(int x, int y, PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out var coord, out var page, out var localX, out var localY)) + { + return false; + } + + if (particle.IsEmpty) + { + return RemoveParticle(x, y); + } + + RegisterParticleProfile(particle); + page.SetCell(localX, localY, particle); + page.SetDriftState(localX, localY, GetDefaultDriftDirection(x, y)); + InitializeRuntimeState(page, localX, localY, particle); + page.LastTouchedFrame = _stepCounter; + page.MarkProcessed(localX, localY, _stepCounter); + MarkChunkActive(coord, page); + World.SetOccupied(x, y, (byte)particle.MotionType); + WakeNeighborsForBorderTouch(coord, localX, localY); + return true; + } + + private void InitializeRuntimeState(ChunkCellPage page, int localX, int localY, PrototypeParticle particle) + { + page.SetPressureDuration(localX, localY, 0f); + page.SetTemperature(localX, localY, particle.InitialTemperature); + page.SetBurnTime(localX, localY, particle.BurnDuration); + page.SetBurning(localX, localY, particle.BurningInit ? (byte)1 : (byte)0); + page.SetSparkTime(localX, localY, 0); + page.SetLifetime(localX, localY, particle.DefaultLifetime); + page.SetCellAge(localX, localY, 0f); + page.SetIntegrity(localX, localY, particle.Durability); + } + + private bool TryResolveProfile(ushort typeId, out PrototypeParticle particle) => _particleProfiles.TryGetValue(typeId, out particle); + + private bool TryFindProfileById(string particleId, out PrototypeParticle particle) + { + foreach (var profile in _particleProfiles.Values) + { + if (profile.TypeId != 0 && string.Equals(profile.Id, particleId, StringComparison.OrdinalIgnoreCase)) + { + particle = profile; + return true; + } + } + + particle = default; + return false; + } + + private ChunkCoord GetChunkCoord(int x, int y) => new(x / _config.ChunkWidth, y / _config.ChunkHeight); + + private (int LocalX, int LocalY) GetLocalCoord(int x, int y) => (x % _config.ChunkWidth, y % _config.ChunkHeight); + + private bool TryGetCellPage(int x, int y, out ChunkCoord coord, out ChunkCellPage page, out int localX, out int localY) + { + coord = default; + page = null!; + localX = 0; + localY = 0; + if (!InBounds(x, y)) + { + return false; + } + + coord = GetChunkCoord(x, y); + if (!_cellPages.TryGetValue(coord, out page!)) + { + return false; + } + + (localX, localY) = GetLocalCoord(x, y); + return true; + } + + private bool TryGetFieldPage(int x, int y, out ChunkCoord coord, out ChunkFieldPage page, out int localX, out int localY) + { + coord = default; + page = null!; + localX = 0; + localY = 0; + if (!InBounds(x, y)) + { + return false; + } + + coord = GetChunkCoord(x, y); + if (!_fieldPages.TryGetValue(coord, out page!)) + { + return false; + } + + (localX, localY) = GetLocalCoord(x, y); + return true; + } + + private ChunkCellPage GetOrCreateCellPage(ChunkCoord coord) + { + if (_cellPages.TryGetValue(coord, out var page)) + { + return page; + } + + page = new ChunkCellPage(_config.ChunkWidth, _config.ChunkHeight); + _cellPages[coord] = page; + return page; + } + + private ChunkFieldPage GetOrCreateFieldPage(ChunkCoord coord) + { + if (_fieldPages.TryGetValue(coord, out var page)) + { + return page; + } + + page = new ChunkFieldPage(_config.ChunkWidth, _config.ChunkHeight); + _fieldPages[coord] = page; + return page; + } + + private int ToWorldX(ChunkCoord coord, int localX) => (coord.X * _config.ChunkWidth) + localX; + + private int ToWorldY(ChunkCoord coord, int localY) => (coord.Y * _config.ChunkHeight) + localY; + + private bool InBounds(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height; +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs new file mode 100644 index 0000000..6176923 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.FieldsAndRendering.cs @@ -0,0 +1,528 @@ +using Sand.Core; +using System.Diagnostics; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + public void ClearFields() + { + _fieldPages.Clear(); + foreach (var page in _cellPages.Values) + { + page.HasFieldActivity = false; + } + + _fullFrameDirty = true; + _dirtyVisualChunks.Clear(); + foreach (var coord in _cellPages.Keys) + { + _dirtyVisualChunks.Add(coord); + } + } + + public (float X, float Y) GetWindAtCell(int x, int y) + { + if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY)) + { + return (0f, 0f); + } + + var cell = page.GetCell(localX, localY); + return (cell.WindX, cell.WindY); + } + + public (float X, float Y) GetForceAtCell(int x, int y) + { + if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY)) + { + return (0f, 0f); + } + + var cell = page.GetCell(localX, localY); + return (cell.ForceX, cell.ForceY); + } + + public float GetAirPressureAtCell(int x, int y) + { + if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY)) + { + return 0f; + } + + return page.GetCell(localX, localY).Pressure; + } + + public ReadOnlySpan BuildRgbaFrame(bool enableWindVisuals = false, bool enablePressureVisuals = false) + { + var renderStart = Stopwatch.GetTimestamp(); + long bytesTouched; + if (_fullFrameDirty) + { + Array.Clear(_rgbaBuffer); + for (var i = 3; i < _rgbaBuffer.Length; i += 4) + { + _rgbaBuffer[i] = 255; + } + + bytesTouched = _rgbaBuffer.Length; + var rendered = new HashSet(); + foreach (var coord in _cellPages.Keys) + { + RenderChunk(coord, enableWindVisuals, enablePressureVisuals); + rendered.Add(coord); + } + + foreach (var coord in _fieldPages.Keys) + { + if (rendered.Add(coord)) + { + RenderChunk(coord, enableWindVisuals, enablePressureVisuals); + } + } + + _dirtyVisualChunks.Clear(); + _fullFrameDirty = false; + } + else + { + bytesTouched = 0; + foreach (var coord in _dirtyVisualChunks) + { + bytesTouched += ClearChunkVisual(coord); + RenderChunk(coord, enableWindVisuals, enablePressureVisuals); + } + + _dirtyVisualChunks.Clear(); + } + + var renderMicros = ToMicroseconds(renderStart, Stopwatch.GetTimestamp()); + _lastStepStats = _lastStepStats with + { + FrameBuildBytesTouched = bytesTouched, + RenderTimeMicroseconds = renderMicros, + }; + return _rgbaBuffer; + } + + public ReadOnlySpan BuildRgbFrame() + { + var rgba = BuildRgbaFrame(); + for (int sourceIndex = 0, targetIndex = 0; sourceIndex < rgba.Length; sourceIndex += 4, targetIndex += 3) + { + _rgbBuffer[targetIndex] = rgba[sourceIndex]; + _rgbBuffer[targetIndex + 1] = rgba[sourceIndex + 1]; + _rgbBuffer[targetIndex + 2] = rgba[sourceIndex + 2]; + } + + return _rgbBuffer; + } + + public int TrimResidency(int marginChunks = 1) + { + var unloaded = World.UnloadEmptyChunks(); + unloaded += World.UnloadInactiveChunks(marginChunks); + + foreach (var coord in _cellPages.Keys.ToArray()) + { + if (_cellPages[coord].OccupancyCount == 0 && !_fieldPages.ContainsKey(coord)) + { + _cellPages.Remove(coord); + } + } + + foreach (var coord in _fieldPages.Keys.ToArray()) + { + if (_fieldPages[coord].IsEmpty()) + { + _fieldPages.Remove(coord); + } + } + + return unloaded; + } + + public void ApplyWindBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY) => + ApplyDirectionalField(centerX, centerY, brushRadius, forceX, forceY, writeWind: true, writePressure: false); + + public void ApplyAirBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY) => + ApplyDirectionalField(centerX, centerY, brushRadius, forceX, forceY, writeWind: true, writePressure: true); + + public void ApplyGravityBrush(int centerX, int centerY, int brushRadius, float strength) => + ApplyRadialField(centerX, centerY, brushRadius, MathF.Abs(strength), inward: true); + + public void ApplyRepulsorBrush(int centerX, int centerY, int brushRadius, float strength) => + ApplyRadialField(centerX, centerY, brushRadius, MathF.Abs(strength), inward: false); + + private void ApplyDirectionalField(int centerX, int centerY, int brushRadius, float forceX, float forceY, bool writeWind, bool writePressure) + { + var radiusSquared = brushRadius * brushRadius; + for (var offsetY = -brushRadius; offsetY <= brushRadius; offsetY++) + { + for (var offsetX = -brushRadius; offsetX <= brushRadius; offsetX++) + { + var distanceSquared = (offsetX * offsetX) + (offsetY * offsetY); + if (distanceSquared > radiusSquared) + { + continue; + } + + var x = centerX + offsetX; + var y = centerY + offsetY; + if (!InBounds(x, y)) + { + continue; + } + + var distance = MathF.Sqrt(distanceSquared); + var falloff = brushRadius <= 0 ? 1f : MathF.Max(0.1f, 1f - (distance / Math.Max(1f, brushRadius))); + var coord = GetChunkCoord(x, y); + var (localX, localY) = GetLocalCoord(x, y); + var fieldPage = GetOrCreateFieldPage(coord); + var cell = fieldPage.GetCell(localX, localY); + fieldPage.SetCell(localX, localY, new FieldCellData( + writeWind ? cell.WindX + (forceX * falloff) : cell.WindX, + writeWind ? cell.WindY + (forceY * falloff) : cell.WindY, + writeWind ? cell.ForceX : cell.ForceX + (forceX * 0.25f * falloff), + writeWind ? cell.ForceY : cell.ForceY + (forceY * 0.25f * falloff), + writePressure ? cell.Pressure + (MathF.Sqrt((forceX * forceX) + (forceY * forceY)) * falloff) : cell.Pressure)); + fieldPage.LastDecayedFrame = _stepCounter; + WakeChunk(coord); + } + } + } + + private void ApplyRadialField(int centerX, int centerY, int brushRadius, float strength, bool inward) + { + var radiusSquared = brushRadius * brushRadius; + for (var offsetY = -brushRadius; offsetY <= brushRadius; offsetY++) + { + for (var offsetX = -brushRadius; offsetX <= brushRadius; offsetX++) + { + var distanceSquared = (offsetX * offsetX) + (offsetY * offsetY); + if (distanceSquared > radiusSquared) + { + continue; + } + + var x = centerX + offsetX; + var y = centerY + offsetY; + if (!InBounds(x, y)) + { + continue; + } + + var distance = MathF.Sqrt(Math.Max(1f, distanceSquared)); + var scale = MathF.Max(0.1f, 1f - (distance / Math.Max(1f, brushRadius))); + var directionX = offsetX / distance; + var directionY = offsetY / distance; + if (inward) + { + directionX = -directionX; + directionY = -directionY; + } + + var coord = GetChunkCoord(x, y); + var (localX, localY) = GetLocalCoord(x, y); + var fieldPage = GetOrCreateFieldPage(coord); + var cell = fieldPage.GetCell(localX, localY); + fieldPage.SetCell(localX, localY, new FieldCellData( + cell.WindX, + cell.WindY, + cell.ForceX + (directionX * strength * scale), + cell.ForceY + (directionY * strength * scale), + cell.Pressure + ((inward ? 1f : -1f) * strength * 0.2f * scale))); + fieldPage.LastDecayedFrame = _stepCounter; + WakeChunk(coord); + } + } + } + + private void DecayFields() + { + foreach (var coord in _fieldPages.Keys.ToArray()) + { + var fieldPage = _fieldPages[coord]; + var changed = false; + foreach (var (localX, localY, cell) in fieldPage.EnumerateActiveCells().ToArray()) + { + var decayed = new FieldCellData( + cell.WindX * FieldDecayFactor, + cell.WindY * FieldDecayFactor, + cell.ForceX * FieldDecayFactor, + cell.ForceY * FieldDecayFactor, + cell.Pressure * FieldDecayFactor); + fieldPage.SetCell(localX, localY, decayed); + changed = true; + } + + if (fieldPage.IsEmpty()) + { + _fieldPages.Remove(coord); + if (_cellPages.TryGetValue(coord, out var cellPage)) + { + cellPage.HasFieldActivity = false; + } + + _dirtyVisualChunks.Add(coord); + continue; + } + + if (changed) + { + fieldPage.LastDecayedFrame = _stepCounter; + _dirtyVisualChunks.Add(coord); + if (_cellPages.TryGetValue(coord, out var cellPage)) + { + cellPage.HasFieldActivity = true; + cellPage.IsActive = true; + } + } + } + } + + private void MarkChunkActive(ChunkCoord coord, ChunkCellPage page) + { + page.IsActive = true; + page.LastTouchedFrame = _stepCounter; + _dirtyVisualChunks.Add(coord); + } + + private void WakeNeighborsForBorderTouch(ChunkCoord coord, int localX, int localY) + { + if (localX == 0) + { + WakeChunk(new ChunkCoord(coord.X - 1, coord.Y), _config.ChunkWidth - 1, localY); + } + + if (localX == _config.ChunkWidth - 1) + { + WakeChunk(new ChunkCoord(coord.X + 1, coord.Y), 0, localY); + } + + if (localY == 0) + { + WakeChunk(new ChunkCoord(coord.X, coord.Y - 1), localX, _config.ChunkHeight - 1); + } + + if (localY == _config.ChunkHeight - 1) + { + WakeChunk(new ChunkCoord(coord.X, coord.Y + 1), localX, 0); + } + } + + private void WakeChunk(ChunkCoord coord, int localX, int localY) + { + if (_cellPages.TryGetValue(coord, out var page)) + { + page.IsActive = true; + page.LastTouchedFrame = _stepCounter; + page.RequestFutureSteps(1); + page.MarkDirtyBand(localX, localY); + } + + _dirtyVisualChunks.Add(coord); + } + + private void WakeChunk(ChunkCoord coord) + { + if (_cellPages.TryGetValue(coord, out var page)) + { + page.IsActive = true; + page.HasFieldActivity = true; + page.LastTouchedFrame = _stepCounter; + page.RequestFutureSteps(1); + } + + _dirtyVisualChunks.Add(coord); + } + + private void RemoveEmptyCellPageIfUnused(ChunkCoord coord, ChunkCellPage page) + { + if (page.OccupancyCount == 0 && !_fieldPages.ContainsKey(coord)) + { + _cellPages.Remove(coord); + } + } + + private long ClearChunkVisual(ChunkCoord coord) + { + long bytesTouched = 0; + var startX = Math.Max(0, (coord.X * _config.ChunkWidth) - RenderHaloCells); + var startY = Math.Max(0, (coord.Y * _config.ChunkHeight) - RenderHaloCells); + var endX = Math.Min(Width - 1, ((coord.X + 1) * _config.ChunkWidth) + RenderHaloCells - 1); + var endY = Math.Min(Height - 1, ((coord.Y + 1) * _config.ChunkHeight) + RenderHaloCells - 1); + for (var worldY = startY; worldY <= endY; worldY++) + { + for (var worldX = startX; worldX <= endX; worldX++) + { + var rgbaIndex = ((worldY * Width) + worldX) * 4; + _rgbaBuffer[rgbaIndex] = 0; + _rgbaBuffer[rgbaIndex + 1] = 0; + _rgbaBuffer[rgbaIndex + 2] = 0; + _rgbaBuffer[rgbaIndex + 3] = 255; + bytesTouched += 4; + } + } + + return bytesTouched; + } + + private void RenderChunk(ChunkCoord coord, bool enableWindVisuals, bool enablePressureVisuals) + { + var startX = Math.Max(0, (coord.X * _config.ChunkWidth) - RenderHaloCells); + var startY = Math.Max(0, (coord.Y * _config.ChunkHeight) - RenderHaloCells); + var endX = Math.Min(Width - 1, ((coord.X + 1) * _config.ChunkWidth) + RenderHaloCells - 1); + var endY = Math.Min(Height - 1, ((coord.Y + 1) * _config.ChunkHeight) + RenderHaloCells - 1); + for (var worldY = startY; worldY <= endY; worldY++) + { + for (var worldX = startX; worldX <= endX; worldX++) + { + var rgbaIndex = ((worldY * Width) + worldX) * 4; + var particle = TryGetParticle(worldX, worldY, out var currentParticle) ? currentParticle : default; + if (particle.TypeId != 0) + { + _rgbaBuffer[rgbaIndex] = particle.R; + _rgbaBuffer[rgbaIndex + 1] = particle.G; + _rgbaBuffer[rgbaIndex + 2] = particle.B; + _rgbaBuffer[rgbaIndex + 3] = 255; + continue; + } + + if (TryResolveGasSeamRenderColor(worldX, worldY, out var gasColor)) + { + _rgbaBuffer[rgbaIndex] = gasColor.R; + _rgbaBuffer[rgbaIndex + 1] = gasColor.G; + _rgbaBuffer[rgbaIndex + 2] = gasColor.B; + _rgbaBuffer[rgbaIndex + 3] = 255; + continue; + } + + var color = ResolveFieldColorAtWorld(worldX, worldY, enableWindVisuals, enablePressureVisuals); + _rgbaBuffer[rgbaIndex] = color.R; + _rgbaBuffer[rgbaIndex + 1] = color.G; + _rgbaBuffer[rgbaIndex + 2] = color.B; + _rgbaBuffer[rgbaIndex + 3] = 255; + } + } + } + + private (byte R, byte G, byte B) ResolveFieldColorAtWorld(int x, int y, bool enableWindVisuals, bool enablePressureVisuals) + { + if (!TryGetFieldPage(x, y, out _, out var fieldPage, out var localX, out var localY)) + { + return (0, 0, 0); + } + + return ResolveFieldColor(fieldPage, localX, localY, enableWindVisuals, enablePressureVisuals); + } + + private bool TryResolveGasSeamRenderColor(int x, int y, out (byte R, byte G, byte B) color) + { + color = default; + if (!IsNearChunkRowSeam(y)) + { + return false; + } + + var gasNeighborCount = 0; + var gasAboveCount = 0; + var gasBelowCount = 0; + var totalR = 0; + var totalG = 0; + var totalB = 0; + for (var offsetY = -1; offsetY <= 1; offsetY++) + { + for (var offsetX = -1; offsetX <= 1; offsetX++) + { + if (offsetX == 0 && offsetY == 0) + { + continue; + } + + var neighborX = x + offsetX; + var neighborY = y + offsetY; + if (!TryGetParticle(neighborX, neighborY, out var particle) || particle.MotionType != PrototypeParticleType.Steam) + { + continue; + } + + gasNeighborCount++; + if (offsetY < 0) + { + gasAboveCount++; + } + else if (offsetY > 0) + { + gasBelowCount++; + } + + totalR += particle.R; + totalG += particle.G; + totalB += particle.B; + } + } + + if (gasNeighborCount < 3 || gasAboveCount == 0 || gasBelowCount == 0) + { + return false; + } + + color = ((byte)(totalR / gasNeighborCount), (byte)(totalG / gasNeighborCount), (byte)(totalB / gasNeighborCount)); + return true; + } + + private bool IsNearChunkRowSeam(int y) + { + var localY = y % _config.ChunkHeight; + return localY == 0 || localY == _config.ChunkHeight - 1; + } + + private static (byte R, byte G, byte B) ResolveFieldColor(ChunkFieldPage? fieldPage, int localX, int localY, bool enableWindVisuals, bool enablePressureVisuals) + { + if (fieldPage is null || !fieldPage.TryGetCell(localX, localY, out var cell)) + { + return (0, 0, 0); + } + + byte r = 0; + byte g = 0; + byte b = 0; + if (enablePressureVisuals) + { + var pressure = MathF.Abs(cell.Pressure); + if (pressure > 0.08f) + { + var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140); + if (cell.Pressure >= 0f) + { + r = (byte)Math.Max((int)r, 26 + tint); + g = (byte)Math.Max((int)g, 18 + (tint / 3)); + b = (byte)Math.Max((int)b, 24); + } + else + { + r = (byte)Math.Max((int)r, 18); + g = (byte)Math.Max((int)g, 24 + (tint / 4)); + b = (byte)Math.Max((int)b, 30 + tint); + } + } + } + + if (enableWindVisuals) + { + var totalFieldX = cell.WindX + cell.ForceX; + var totalFieldY = cell.WindY + cell.ForceY; + var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY)); + if (windStrength > 0.08f) + { + var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120); + r = (byte)Math.Max((int)r, 16 + (tint / 3)); + g = (byte)Math.Max((int)g, 24 + (tint / 2)); + b = (byte)Math.Max((int)b, 32 + tint); + } + } + + return (r, g, b); + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs new file mode 100644 index 0000000..ea25e62 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs @@ -0,0 +1,1061 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private void TryStepParticle(ChunkCoord coord, int localX, int localY, PrototypeParticle particle) + { + var worldX = ToWorldX(coord, localX); + var worldY = ToWorldY(coord, localY); + var seed = unchecked(_stepCounter + (worldX * 31) + (worldY * 131)); + if (CanUseMovementFastPath(worldX, worldY, particle)) + { + ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY); + return; + } + + _fullRuntimeStepCount++; + switch (particle.Kind) + { + case ParticleKind.Solid: + _fullRuntimeSolidCount++; + break; + case ParticleKind.Liquid: + _fullRuntimeLiquidCount++; + break; + case ParticleKind.Gas: + _fullRuntimeGasCount++; + break; + } + + if (NeedsRuntimeAging(particle)) + { + TickCellAging(worldX, worldY, particle); + } + + if (particle.HasLifetimeBehavior && TickLifetime(worldX, worldY, ref particle)) + { + return; + } + + if (particle.HasBurnBehavior) + { + AutoIgnite(worldX, worldY, particle); + } + + if (NeedsRuntimeEmission(particle)) + { + ApplyRuntimeEmissionAndProduction(worldX, worldY, particle, ref seed); + } + + if (particle.HasBurnBehavior && TickBurning(worldX, worldY, ref particle)) + { + return; + } + + if (particle.HasSpecialBehavior && TickSpecialBehavior(worldX, worldY, particle, ref seed)) + { + return; + } + + if (particle.HasPhaseBehavior) + { + ApplyPhaseTransition(worldX, worldY, ref particle); + if (!TryRefreshRuntimeParticle(worldX, worldY, ref particle)) + { + return; + } + } + + if (particle.HasReactionBehavior && ApplyLocalReaction(worldX, worldY, particle, ref seed)) + { + return; + } + + if (particle.HasReactionBehavior && !TryRefreshRuntimeParticle(worldX, worldY, ref particle)) + { + return; + } + + if (particle.HasPressureBehavior && ApplyPressureResponse(worldX, worldY, particle)) + { + return; + } + + if (particle.HasPressureBehavior && !TryRefreshRuntimeParticle(worldX, worldY, ref particle)) + { + return; + } + + if (particle.HasThermalBehavior) + { + ApplyTemperatureDiffusion(worldX, worldY, particle); + } + + if (particle.IsStatic) + { + return; + } + + ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY); + } + + private static bool NeedsRuntimeAging(PrototypeParticle particle) => + particle.PhaseTransitionHysteresis > 0f || particle.HasPressureBehavior; + + private static bool NeedsRuntimeEmission(PrototypeParticle particle) => + particle.HasEmissionBehavior || particle.IsMolten; + + private bool TryRefreshRuntimeParticle(int x, int y, ref PrototypeParticle particle) => + TryGetParticle(x, y, out particle); + + private void ApplyMovementOnly(ChunkCoord coord, int localX, int localY, PrototypeParticle particle, ref int seed, int worldX, int worldY) + { + if (particle.IsStatic) + { + return; + } + + var (netForceX, netForceY) = GetNetForce(worldX, worldY, particle); + netForceX *= GetForceResponse(particle); + netForceY *= GetForceResponse(particle); + var horizontalPreference = ResolveHorizontalPreference(coord, localX, localY, particle, netForceX); + var leftFirst = horizontalPreference < 0; + if (netForceX > 0.25f) + { + leftFirst = false; + } + else if (netForceX < -0.25f) + { + leftFirst = true; + } + + switch (particle.BehaviorKind) + { + case ParticleBehaviorKind.Fire: + MoveFire(worldX, worldY, leftFirst, ref seed, netForceX); + return; + case ParticleBehaviorKind.BurningWood: + return; + case ParticleBehaviorKind.Ember: + MoveEmber(worldX, worldY, particle, leftFirst, ref seed, netForceX); + return; + case ParticleBehaviorKind.Plasma: + MovePlasma(worldX, worldY, particle, leftFirst, ref seed, netForceX, netForceY); + return; + } + + switch (particle.Kind) + { + case ParticleKind.Solid: + TryMoveSolid(coord, localX, localY, worldX, worldY, particle, leftFirst, ref seed, netForceX); + break; + case ParticleKind.Liquid: + TryMoveLiquid(worldX, worldY, particle, leftFirst, ref seed, netForceX); + break; + case ParticleKind.Gas: + TryMoveGas(worldX, worldY, particle, leftFirst, ref seed, netForceX, netForceY); + break; + } + } + + private void StepMovementOnlyParticle(ChunkCoord coord, int localX, int localY, PrototypeParticle particle) + { + _movementOnlyFastPathCount++; + var worldX = ToWorldX(coord, localX); + var worldY = ToWorldY(coord, localY); + var seed = unchecked(_stepCounter + (worldX * 31) + (worldY * 131)); + ApplyMovementOnly(coord, localX, localY, particle, ref seed, worldX, worldY); + } + + private bool CanUseMovementFastPath(int x, int y, PrototypeParticle particle) + { + if (particle.IsStatic) + { + return false; + } + + if (!particle.RequiresFullRuntimeStep) + { + return true; + } + + if (particle.HasLifetimeBehavior || particle.HasBurnBehavior || particle.HasEmissionBehavior || particle.HasSpecialBehavior) + { + return false; + } + + if (particle.HasPressureBehavior && (GetLocalPressure(x, y) > 0.05f || GetFieldForceMagnitude(x, y) > 0.08f || GetPressureDurationAt(x, y) > 0f || GetIntegrityAt(x, y) < particle.Durability)) + { + return false; + } + + if (particle.HasPhaseBehavior) + { + var temperature = GetTemperatureAt(x, y); + if ((particle.MeltTypeId != 0 && temperature >= particle.MeltTemperature) || + (particle.EvaporateTypeId != 0 && temperature >= particle.EvaporateTemperature) || + (particle.FreezeTypeId != 0 && temperature <= particle.FreezeTemperature) || + (particle.SolidifyTypeId != 0 && + (temperature <= particle.SolidifyTemperature || + (particle.MotionType == PrototypeParticleType.Steam && GetPressureLoad(x, y) >= MathF.Max(0.9f, particle.PressureThreshold * 0.35f))))) + { + return false; + } + } + + if (particle.HasReactionBehavior && HasPotentialReactiveNeighbor(x, y, particle)) + { + return false; + } + + if (particle.HasThermalBehavior) + { + var temperature = GetTemperatureAt(x, y); + if (CanThrottleGasRuntime(x, y, particle, temperature)) + { + return true; + } + + if (MathF.Abs(temperature - _ambientTemperature) > 0.5f) + { + return false; + } + } + + return true; + } + + private bool TryMoveSolid(ChunkCoord coord, int localX, int localY, int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX) + { + if (TrySkipBlockedSolid(coord, localX, localY, netForceX)) + { + return false; + } + + if (TryMoveSolidDown(x, y, particle)) + { + return true; + } + + var canMoveLeft = CanMoveIntoEmpty(x - 1, y + 1); + var canMoveRight = CanMoveIntoEmpty(x + 1, y + 1); + if (!canMoveLeft && !canMoveRight) + { + CacheBlockedSolid(coord, localX, localY); + return false; + } + + if (!CanSlipSolid(particle, ref seed, netForceX)) + { + RecordStalledMovable(); + KeepChunkAwakeAt(x, y); + return false; + } + + if (leftFirst) + { + if (canMoveLeft && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return true; + } + + if (canMoveRight && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return true; + } + } + else + { + if (canMoveRight && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return true; + } + + if (canMoveLeft && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return true; + } + } + + RecordStalledMovable(); + KeepChunkAwakeAt(x, y); + return false; + } + + private bool TryMoveLiquid(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX) + { + if (TryMoveLiquidDown(x, y, particle)) + { + return true; + } + + var windPreferred = netForceX > 0.2f ? 1 : netForceX < -0.2f ? -1 : 0; + if (windPreferred != 0 && CanFlowLiquid(particle, ref seed, lateral: true, netForceX) && TryMoveEmpty(x, y, x + windPreferred, y)) + { + return true; + } + + var diagonalAllowed = CanFlowLiquid(particle, ref seed, lateral: false, netForceX); + var lateralAllowed = diagonalAllowed || CanFlowLiquid(particle, ref seed, lateral: true, netForceX); + var openLeftDiagonal = CanMoveIntoEmpty(x - 1, y + 1); + var openRightDiagonal = CanMoveIntoEmpty(x + 1, y + 1); + var openLeftLateral = CanMoveIntoEmpty(x - 1, y); + var openRightLateral = CanMoveIntoEmpty(x + 1, y); + if (leftFirst) + { + if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return true; + } + + if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y)) + { + return true; + } + + if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return true; + } + + if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y)) + { + return true; + } + } + else + { + if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return true; + } + + if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y)) + { + return true; + } + + if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return true; + } + + if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y)) + { + return true; + } + } + + if (openLeftDiagonal || openRightDiagonal || openLeftLateral || openRightLateral) + { + RecordStalledMovable(); + KeepChunkAwakeAt(x, y); + } + + return false; + } + + private bool TryMoveGas(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX, float netForceY) + { + var suppressRiseAndDiagonal = TrySkipGasRetry(x, y); + if (suppressRiseAndDiagonal && CanMoveIntoEmpty(x, y - 1)) + { + ClearGasRetry(x, y); + suppressRiseAndDiagonal = false; + } + + var openAbove = false; + if (!suppressRiseAndDiagonal) + { + openAbove = CanMoveIntoEmpty(x, y - 1); + if (openAbove) + { + ClearBlockedGasRise(x, y); + ClearGasRetry(x, y); + if (CanRiseGas(particle, ref seed, netForceY) && TryMoveEmpty(x, y, x, y - 1)) + { + return true; + } + } + else if (!TrySkipBlockedGasRise(x, y) && InBounds(x, y - 1)) + { + CacheBlockedGasRise(x, y); + } + } + + var windPreferred = netForceX > 0.15f ? 1 : netForceX < -0.15f ? -1 : 0; + if (windPreferred != 0 && CanDriftGas(particle, ref seed, netForceX, lateral: true) && TryMoveEmpty(x, y, x + windPreferred, y)) + { + ClearGasRetry(x, y); + return true; + } + + var diagonalAllowed = CanDriftGas(particle, ref seed, netForceX, lateral: false); + var lateralAllowed = diagonalAllowed || CanDriftGas(particle, ref seed, netForceX, lateral: true); + if (suppressRiseAndDiagonal) + { + diagonalAllowed = false; + } + + var openLeftDiagonal = !suppressRiseAndDiagonal && CanMoveIntoEmpty(x - 1, y - 1); + var openRightDiagonal = !suppressRiseAndDiagonal && CanMoveIntoEmpty(x + 1, y - 1); + var openLeftLateral = CanMoveIntoEmpty(x - 1, y); + var openRightLateral = CanMoveIntoEmpty(x + 1, y); + if (leftFirst) + { + if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y - 1)) + { + ClearGasRetry(x, y); + return true; + } + + if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y)) + { + ClearGasRetry(x, y); + return true; + } + + if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y - 1)) + { + ClearGasRetry(x, y); + return true; + } + + if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y)) + { + ClearGasRetry(x, y); + return true; + } + } + else + { + if (diagonalAllowed && openRightDiagonal && TryMoveEmpty(x, y, x + 1, y - 1)) + { + ClearGasRetry(x, y); + return true; + } + + if (lateralAllowed && openRightLateral && TryMoveEmpty(x, y, x + 1, y)) + { + ClearGasRetry(x, y); + return true; + } + + if (diagonalAllowed && openLeftDiagonal && TryMoveEmpty(x, y, x - 1, y - 1)) + { + ClearGasRetry(x, y); + return true; + } + + if (lateralAllowed && openLeftLateral && TryMoveEmpty(x, y, x - 1, y)) + { + ClearGasRetry(x, y); + return true; + } + } + + if (!openAbove && !openLeftDiagonal && !openRightDiagonal) + { + CacheGasRetry(x, y, (openLeftLateral || openRightLateral) ? 1 : 2); + } + else + { + ClearGasRetry(x, y); + } + + if (openLeftDiagonal || openRightDiagonal || openLeftLateral || openRightLateral) + { + RecordStalledMovable(); + KeepChunkAwakeAt(x, y); + } + + return false; + } + + private void MoveFire(int x, int y, bool leftFirst, ref int seed, float netForceX) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + var preferredRight = netForceX > 0.05f ? true : netForceX < -0.05f ? false : !leftFirst; + if (preferredRight) + { + if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + else + { + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + + if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + } + + if (NextChance(ref seed) <= 0.15f) + { + RemoveParticle(x, y); + } + } + + private void MoveEmber(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX) + { + if (NextChance(ref seed) <= MathF.Max(0.1f, particle.UpwardBias)) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + if (leftFirst) + { + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + else if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + } + + TryMoveSolid(GetChunkCoord(x, y), GetLocalCoord(x, y).LocalX, GetLocalCoord(x, y).LocalY, x, y, particle, leftFirst, ref seed, netForceX); + } + + private void MovePlasma(int x, int y, PrototypeParticle particle, bool leftFirst, ref int seed, float netForceX, float netForceY) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + var lateralBias = MathF.Max(0.15f, particle.SideDriftBias); + if (NextChance(ref seed) <= lateralBias) + { + var dir = netForceX > 0.05f ? 1 : netForceX < -0.05f ? -1 : leftFirst ? -1 : 1; + if (TryMoveEmpty(x, y, x + dir, y - 1) || TryMoveEmpty(x, y, x + dir, y)) + { + return; + } + } + + TryMoveGas(x, y, particle, leftFirst, ref seed, netForceX, netForceY); + } + + private bool TryMoveSolidDown(int x, int y, PrototypeParticle particle) + { + if (TryMoveEmpty(x, y, x, y + 1)) + { + return true; + } + + if (!TryGetParticle(x, y + 1, out var target)) + { + return false; + } + + if ((target.Kind is ParticleKind.Liquid or ParticleKind.Gas) && particle.Mass > target.Mass) + { + RecordSwapAttempt(); + return SwapParticles(x, y, x, y + 1); + } + + return false; + } + + private bool TryMoveLiquidDown(int x, int y, PrototypeParticle particle) + { + if (TryMoveEmpty(x, y, x, y + 1)) + { + return true; + } + + if (!TryGetParticle(x, y + 1, out var target)) + { + return false; + } + + if (target.Kind == ParticleKind.Gas && particle.Mass > target.Mass) + { + RecordSwapAttempt(); + return SwapParticles(x, y, x, y + 1); + } + + if (particle.IsMolten && target.Kind == ParticleKind.Liquid && particle.Mass > target.Mass) + { + RecordSwapAttempt(); + return SwapParticles(x, y, x, y + 1); + } + + return false; + } + + private bool TryMoveEmpty(int fromX, int fromY, int toX, int toY) + { + RecordMoveAttempt(toX - fromX, toY - fromY); + if (!InBounds(toX, toY)) + { + return false; + } + + if (TryGetCellPage(fromX, fromY, out var sourceCoord, out var sourcePage, out var sourceLocalX, out var sourceLocalY)) + { + var destinationCoord = GetChunkCoord(toX, toY); + if (destinationCoord == sourceCoord) + { + var (destinationLocalX, destinationLocalY) = GetLocalCoord(toX, toY); + if (!sourcePage.IsOccupied(destinationLocalX, destinationLocalY)) + { + return MoveParticleWithinPage(sourceCoord, sourcePage, sourceLocalX, sourceLocalY, destinationLocalX, destinationLocalY, fromX, fromY, toX, toY); + } + + return false; + } + } + + if (!CanMoveIntoEmpty(toX, toY)) + { + return false; + } + + return MoveParticle(fromX, fromY, toX, toY); + } + + private bool CanMoveIntoEmpty(int x, int y) => InBounds(x, y) && !HasParticle(x, y); + + private int ResolveHorizontalPreference(ChunkCoord coord, int localX, int localY, PrototypeParticle particle, float netForceX) + { + if (netForceX > 0.15f) + { + return 1; + } + + if (netForceX < -0.15f) + { + return -1; + } + + var page = _cellPages[coord]; + var driftState = page.GetDriftState(localX, localY); + if (driftState != 0) + { + return driftState; + } + + return PreferLeft(unchecked((coord.X * 92821) + (coord.Y * 68917) + (localX * 31) + (localY * 131) + _stepCounter + particle.TypeId)) + ? -1 + : 1; + } + + private (float X, float Y) GetNetForce(int x, int y, PrototypeParticle particle) + { + if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY)) + { + var gradientXOnly = SamplePressure(x - 1, y) - SamplePressure(x + 1, y); + var gradientYOnly = SamplePressure(x, y + 1) - SamplePressure(x, y - 1); + return (gradientXOnly * 0.12f * particle.PressureResponse, gradientYOnly * 0.08f * particle.PressureResponse); + } + + var cell = page.GetCell(localX, localY); + var forceX = cell.WindX + cell.ForceX; + var forceY = cell.WindY + cell.ForceY; + var pressureGradientX = SamplePressure(x - 1, y) - SamplePressure(x + 1, y); + var pressureGradientY = SamplePressure(x, y + 1) - SamplePressure(x, y - 1); + forceX += pressureGradientX * 0.12f * particle.PressureResponse; + forceY += pressureGradientY * 0.08f * particle.PressureResponse; + if (particle.MotionType == PrototypeParticleType.Steam) + { + forceY -= 0.2f; + } + + if (MathF.Abs(cell.Pressure) > 0.05f) + { + forceY += particle.MotionType == PrototypeParticleType.Steam + ? -cell.Pressure * 0.05f * particle.PressureResponse + : cell.Pressure * 0.05f * particle.PressureResponse; + } + + return (forceX, forceY); + } + + private static float GetEffectiveVelocity(PrototypeParticle particle) + { + if (particle.Velocity > 0f) + { + return particle.Velocity; + } + + return particle.Kind switch + { + ParticleKind.Solid => 0.45f, + ParticleKind.Liquid => 0.35f, + ParticleKind.Gas => 0.25f, + _ => 0.35f, + }; + } + + private static float GetForceResponse(PrototypeParticle particle) + { + var mobility = 0.35f + GetEffectiveVelocity(particle); + var damping = MathF.Max(0.25f, (particle.Mass * 0.85f) + (particle.Friction * 0.35f) + (particle.Viscosity * 0.45f)); + return Math.Clamp((mobility / damping) * particle.ForceResponseMultiplier, 0.15f, 2f); + } + + private static bool CanSlipSolid(PrototypeParticle particle, ref int seed, float netForceX) + { + if (MathF.Abs(netForceX) > 0.45f) + { + return true; + } + + var chance = Math.Clamp(0.2f + (GetEffectiveVelocity(particle) * 0.95f) - (particle.Friction * 0.35f), 0.1f, 0.95f); + return NextChance(ref seed) <= chance; + } + + private static bool CanFlowLiquid(PrototypeParticle particle, ref int seed, bool lateral, float netForceX) + { + var forceBonus = MathF.Min(0.25f, MathF.Abs(netForceX) * 0.08f); + var velocity = GetEffectiveVelocity(particle); + var baseChance = lateral + ? 0.18f + (velocity * 0.95f) - (particle.Viscosity * 0.42f) - (particle.Friction * 0.08f) + : 0.28f + (velocity * 0.9f) - (particle.Viscosity * 0.28f) - (particle.Friction * 0.05f); + var flowScale = lateral ? particle.LateralFlowMultiplier : particle.DiagonalFlowMultiplier; + var chance = Math.Clamp((baseChance * flowScale) + forceBonus, 0.03f, 0.98f); + return NextChance(ref seed) <= chance; + } + + private static bool CanRiseGas(PrototypeParticle particle, ref int seed, float netForceY) + { + if (netForceY < -0.65f) + { + return true; + } + + var velocity = GetEffectiveVelocity(particle); + var buoyancy = MathF.Max(0f, -netForceY) * 0.08f; + var chance = Math.Clamp(0.1f + (velocity * 1.8f) - (particle.Viscosity * 0.22f) + buoyancy, 0.05f, 0.99f); + return NextChance(ref seed) <= chance; + } + + private static bool CanDriftGas(PrototypeParticle particle, ref int seed, float netForceX, bool lateral) + { + var forceBonus = MathF.Min(0.28f, MathF.Abs(netForceX) * 0.08f); + var velocity = GetEffectiveVelocity(particle); + var baseChance = lateral + ? 0.18f + (velocity * 1.15f) - (particle.Viscosity * 0.1f) + : 0.22f + (velocity * 1.2f) - (particle.Viscosity * 0.16f); + var chance = Math.Clamp(baseChance + forceBonus - (particle.Friction * 0.04f), 0.05f, 0.99f); + return NextChance(ref seed) <= chance; + } + + private static sbyte GetDefaultDriftDirection(int x, int y) => PreferLeft(unchecked((x * 73856093) ^ (y * 19349663))) ? (sbyte)-1 : (sbyte)1; + + private bool TrySkipBlockedSolid(ChunkCoord coord, int localX, int localY, float netForceX) + { + if (MathF.Abs(netForceX) > 0.1f || + localX <= 0 || + localX >= _config.ChunkWidth - 1 || + localY >= _config.ChunkHeight - 1 || + !_cellPages.TryGetValue(coord, out var page)) + { + return false; + } + + var signature = ComputeSolidBlockSignature(page, localY); + return signature != 0 && page.GetBlockedSolidSignature(localX, localY) == signature; + } + + private void CacheBlockedSolid(ChunkCoord coord, int localX, int localY) + { + if (localX <= 0 || localX >= _config.ChunkWidth - 1 || localY >= _config.ChunkHeight - 1) + { + return; + } + + if (_cellPages.TryGetValue(coord, out var page)) + { + page.SetBlockedSolidSignature(localX, localY, ComputeSolidBlockSignature(page, localY)); + } + } + + private static int ComputeSolidBlockSignature(ChunkCellPage page, int localY) + { + if (localY < 0 || localY >= page.Height - 1) + { + return 0; + } + + return HashCode.Combine(page.GetRowRevision(localY), page.GetRowRevision(localY + 1)); + } + + private bool CanThrottleGasRuntime(int x, int y, PrototypeParticle particle, float temperature) + { + if (particle.Kind != ParticleKind.Gas || + particle.HasLifetimeBehavior || + particle.HasBurnBehavior || + particle.HasEmissionBehavior || + particle.HasSpecialBehavior || + particle.HasReactionBehavior) + { + return false; + } + + var requiresPressureSensitiveRuntime = particle.HasPressureBehavior || particle.HasPhaseBehavior; + if (requiresPressureSensitiveRuntime && + (GetPressureLoad(x, y) > 0.2f || GetPressureDurationAt(x, y) > 0f)) + { + return false; + } + + if (MathF.Abs(temperature - _ambientTemperature) <= 0.5f) + { + return false; + } + + if (HasThermallyActiveNeighbor(x, y, temperature, out var hasPassiveBoundaryNeighbor)) + { + return false; + } + + if (hasPassiveBoundaryNeighbor) + { + return ((_stepCounter + (x * 3) + (y * 5)) & 3) != 0; + } + + return true; + } + + private bool HasThermallyActiveNeighbor(int x, int y, float localTemperature, out bool hasPassiveBoundaryNeighbor) + { + hasPassiveBoundaryNeighbor = false; + Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]; + for (var i = 0; i < neighbors.Length; i++) + { + var (nx, ny) = neighbors[i]; + if (!TryGetParticle(nx, ny, out var neighbor)) + { + continue; + } + + var neighborTemperature = GetTemperatureAt(nx, ny); + if (MathF.Abs(neighborTemperature - localTemperature) > 8f) + { + return true; + } + + if (neighbor.HasBurnBehavior || + neighbor.HasEmissionBehavior || + neighbor.HasSpecialBehavior || + neighbor.HasFlag(PrototypeParticleFlags.HotSource)) + { + return true; + } + + if (neighbor.HasReactionBehavior || neighbor.HasPhaseBehavior || neighbor.HasPressureBehavior) + { + return true; + } + + if (neighbor.Kind == ParticleKind.Gas) + { + continue; + } + + if (neighbor.IsStatic || neighbor.MotionType == PrototypeParticleType.Wall) + { + hasPassiveBoundaryNeighbor = true; + continue; + } + + if (neighbor.Conductivity > 0.25f || MathF.Abs(neighborTemperature - localTemperature) > 24f) + { + hasPassiveBoundaryNeighbor = true; + continue; + } + } + + return false; + } + + private bool TrySkipBlockedGasRise(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || localY <= 0) + { + return false; + } + + if (!page.IsOccupied(localX, localY - 1)) + { + return false; + } + + var signature = ComputeGasRiseBlockSignature(page, localX, localY); + return signature != 0 && page.GetBlockedGasRiseSignature(localX, localY) == signature; + } + + private void CacheBlockedGasRise(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || localY <= 0) + { + return; + } + + if (!page.IsOccupied(localX, localY - 1)) + { + page.SetBlockedGasRiseSignature(localX, localY, 0); + return; + } + + page.SetBlockedGasRiseSignature(localX, localY, ComputeGasRiseBlockSignature(page, localX, localY)); + } + + private void ClearBlockedGasRise(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + page.SetBlockedGasRiseSignature(localX, localY, 0); + } + + private static int ComputeGasRiseBlockSignature(ChunkCellPage page, int localX, int localY) + { + if (localY <= 0 || localY >= page.Height) + { + return 0; + } + + return HashCode.Combine(localX, page.GetRowRevision(localY), page.GetRowRevision(localY - 1)); + } + + private bool TrySkipGasRetry(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return false; + } + + var signature = ComputeGasRetrySignature(page, localX, localY); + if (signature == 0) + { + page.ClearGasRetryState(localX, localY); + return false; + } + + if (page.GetGasRetryUntilStep(localX, localY) > _stepCounter && page.GetGasRetrySignature(localX, localY) == signature) + { + return true; + } + + if (page.GetGasRetrySignature(localX, localY) != signature) + { + page.ClearGasRetryState(localX, localY); + } + + return false; + } + + private void CacheGasRetry(int x, int y, int cooldownSteps) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + var signature = ComputeGasRetrySignature(page, localX, localY); + if (signature == 0) + { + page.ClearGasRetryState(localX, localY); + return; + } + + page.SetGasRetryState(localX, localY, signature, _stepCounter + Math.Max(1, cooldownSteps)); + } + + private void ClearGasRetry(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + page.ClearGasRetryState(localX, localY); + } + + private static int ComputeGasRetrySignature(ChunkCellPage page, int localX, int localY) + { + if (localY <= 0 || localY >= page.Height) + { + return 0; + } + + return HashCode.Combine( + localX, + page.GetRowRevision(localY), + page.GetRowRevision(localY - 1), + page.GetOccupiedRowCount(localY), + page.GetOccupiedRowCount(localY - 1)); + } + + private static bool PreferLeft(int seed) => ((seed ^ 0x5bd1e995) & 1) == 0; + + private static float NextChance(ref int seed) + { + seed = unchecked((seed * 1103515245) + 12345); + return ((seed >> 8) & 0x00FFFFFF) / 16777215f; + } + + private static sbyte ResolveDriftAfterMove(int fromX, int toX, sbyte currentDrift) + { + if (toX > fromX) + { + return 1; + } + + if (toX < fromX) + { + return -1; + } + + return currentDrift; + } + + private static PrototypeParticle CreateLegacyParticle(PrototypeParticleType type) => type switch + { + PrototypeParticleType.Sand => new PrototypeParticle(1, "sand", type, ParticleKind.Solid, ParticleBehaviorKind.None, 214, 188, 96, 1.4f, 0.65f, 0.18f, 0.08f), + PrototypeParticleType.Water => new PrototypeParticle(2, "water", type, ParticleKind.Liquid, ParticleBehaviorKind.None, 72, 132, 232, 1.0f, 0.55f, 0.04f, 0.12f, Flags: PrototypeParticleFlags.WaterLike), + PrototypeParticleType.Steam => new PrototypeParticle(3, "steam", type, ParticleKind.Gas, ParticleBehaviorKind.None, 182, 196, 214, 0.2f, 0.7f, 0.01f, 0.03f, SolidifyTypeId: 2, PressureThreshold: 1.2f, InitialTemperature: 110f), + PrototypeParticleType.Wall => new PrototypeParticle(4, "wall", type, ParticleKind.Solid, ParticleBehaviorKind.None, 96, 96, 104, 100f, 0f, 1f, 1f, IsStatic: true), + _ => default, + }; + + private void KeepChunkAwakeAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + page.IsActive = true; + page.LastTouchedFrame = _stepCounter; + page.RequestFutureSteps(2); + page.MarkDirtyBand(localX, localY); + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs new file mode 100644 index 0000000..7aff53e --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Lifecycle.cs @@ -0,0 +1,186 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private void TickCellAging(int x, int y, PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + page.SetCellAge(localX, localY, page.GetCellAge(localX, localY) + 1f); + if (page.GetIntegrity(localX, localY) < particle.Durability) + { + page.SetIntegrity(localX, localY, MathF.Min(particle.Durability, page.GetIntegrity(localX, localY) + MathF.Max(0.02f, particle.Hardness * 0.03f))); + } + } + + private bool TickLifetime(int x, int y, ref PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return false; + } + + var lifetime = page.GetLifetime(localX, localY); + if (lifetime > 0f) + { + lifetime -= 1f; + page.SetLifetime(localX, localY, lifetime); + } + + if (lifetime <= 0f && particle.DefaultLifetime > 0f) + { + if (particle.MotionType == PrototypeParticleType.Steam && particle.SolidifyTypeId != 0 && TryResolveProfile(particle.SolidifyTypeId, out var condensed)) + { + var currentTemperature = GetTemperatureAt(x, y); + ReplaceParticle(x, y, condensed); + if (TryGetCellPage(x, y, out _, out var targetPage, out var targetLocalX, out var targetLocalY)) + { + targetPage.SetTemperature(targetLocalX, targetLocalY, MathF.Max(condensed.InitialTemperature, MathF.Min(currentTemperature, 95f))); + } + + particle = condensed; + return false; + } + + RemoveParticle(x, y); + return true; + } + + return false; + } + + private void AutoIgnite(int x, int y, PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + if (page.GetBurning(localX, localY) == 0 && + particle.BurnTemperature > 0f && + GetTemperatureAt(x, y) >= particle.BurnTemperature && + particle.Flamability > 0f) + { + page.SetBurning(localX, localY, 1); + page.SetBurnTime(localX, localY, MathF.Max(particle.BurnDuration, 1f)); + } + } + + private void ApplyRuntimeEmissionAndProduction(int x, int y, PrototypeParticle particle, ref int seed) + { + var isEffectEmitter = + particle.BehaviorKind is ParticleBehaviorKind.Fire or ParticleBehaviorKind.BurningWood or ParticleBehaviorKind.Ember or ParticleBehaviorKind.Plasma; + var emission = particle.HeatEmission; + if (particle.BehaviorKind == ParticleBehaviorKind.Fire && emission <= 0f) + { + emission = 24f; + } + else if (particle.BehaviorKind == ParticleBehaviorKind.Plasma && emission <= 0f) + { + emission = 80f; + } + else if (particle.BehaviorKind == ParticleBehaviorKind.Ember && emission <= 0f) + { + emission = 12f; + } + else if (particle.IsMolten && emission <= 0f) + { + emission = particle.TypeId != 0 ? 28f : 0f; + } + + if (emission > 0f) + { + ApplyHeatEmission(x, y, emission); + } + + if ((isEffectEmitter || IsBurningAt(x, y)) && particle.ProduceTypeId != 0 && NextChance(ref seed) <= 0.08f) + { + TrySpawnAtOffset(x, y, 0, -1, particle.ProduceTypeId); + } + + if (particle.SmokeSpawnChance > 0f && isEffectEmitter && TryFindProfileById("smoke", out var smoke) && NextChance(ref seed) <= particle.SmokeSpawnChance) + { + TrySpawnAtOffset(x, y, 0, -1, smoke.TypeId); + } + + if (particle.EmberSpawnChance > 0f && isEffectEmitter && TryFindProfileById("ember", out var ember) && NextChance(ref seed) <= particle.EmberSpawnChance) + { + TrySpawnAtOffset(x, y, 0, -1, ember.TypeId); + } + } + + private bool TickBurning(int x, int y, ref PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY) || page.GetBurning(localX, localY) == 0) + { + return false; + } + + page.SetTemperature(localX, localY, page.GetTemperature(localX, localY) + 1.5f + (particle.HeatEmission * 0.02f)); + page.SetBurnTime(localX, localY, page.GetBurnTime(localX, localY) - MathF.Max(0.05f, particle.BurnRate)); + if (page.GetBurnTime(localX, localY) > 0f) + { + return false; + } + + RemoveParticle(x, y); + return true; + } + + private bool TickSpecialBehavior(int x, int y, PrototypeParticle particle, ref int seed) + { + var temperature = GetTemperatureAt(x, y); + switch (particle.BehaviorKind) + { + case ParticleBehaviorKind.Fire: + if (temperature < 120f && NextChance(ref seed) <= 0.1f) + { + RemoveParticle(x, y); + return true; + } + break; + case ParticleBehaviorKind.Ember: + if (temperature < 60f && NextChance(ref seed) <= 0.1f) + { + RemoveParticle(x, y); + return true; + } + break; + case ParticleBehaviorKind.BurningWood: + if (particle.EmberSpawnChance > 0f && TryFindProfileById("ember", out var ember) && NextChance(ref seed) <= particle.EmberSpawnChance * 0.5f) + { + TrySpawnAtOffset(x, y, 0, -1, ember.TypeId); + } + break; + } + + return false; + } + + private bool IsBurningAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return false; + } + + return page.GetBurning(localX, localY) != 0; + } + + private bool TrySpawnAtOffset(int x, int y, int offsetX, int offsetY, ushort typeId) + { + var tx = x + offsetX; + var ty = y + offsetY; + if (!InBounds(tx, ty) || HasParticle(tx, ty) || !TryResolveProfile(typeId, out var particle)) + { + return false; + } + + return AddParticle(tx, ty, particle); + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs new file mode 100644 index 0000000..2abf131 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Pressure.cs @@ -0,0 +1,125 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private bool ApplyPressureResponse(int x, int y, PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return false; + } + + var localPressure = GetLocalPressure(x, y); + var threshold = particle.PressureThreshold; + if (threshold <= 0f) + { + threshold = particle.IsStatic + ? 2.5f + particle.PressureResistance + particle.PressureTolerance + : 1.4f + (particle.PressureResistance * 0.35f) + particle.PressureTolerance; + } + + if (localPressure < threshold) + { + var reducedDuration = MathF.Max(0f, page.GetPressureDuration(localX, localY) - 1f); + page.SetPressureDuration(localX, localY, reducedDuration); + var maxIntegrity = particle.Durability; + if (page.GetIntegrity(localX, localY) < maxIntegrity) + { + page.SetIntegrity(localX, localY, MathF.Min(maxIntegrity, page.GetIntegrity(localX, localY) + MathF.Max(0.2f, particle.Hardness * 0.1f))); + } + + return false; + } + + var pressureDuration = page.GetPressureDuration(localX, localY) + 1f; + page.SetPressureDuration(localX, localY, pressureDuration); + var overload = MathF.Max(0f, localPressure - threshold); + var damageScale = 1f / MathF.Max(0.35f, 0.4f + particle.Hardness + (particle.PressureResistance * 0.1f)); + page.SetIntegrity(localX, localY, page.GetIntegrity(localX, localY) - MathF.Max(0.15f, 0.45f + overload) * damageScale); + var requiredDuration = particle.PressureThresholdDuration > 0 ? particle.PressureThresholdDuration : (short)2; + if (pressureDuration < requiredDuration) + { + return false; + } + + if (particle.BrokenTypeId != 0 && TryResolveProfile(particle.BrokenTypeId, out var broken)) + { + ReplaceParticle(x, y, broken); + return true; + } + + if (particle.Flamability > 0f && GetTemperatureAt(x, y) >= MathF.Max(60f, particle.BurnTemperature * 0.5f)) + { + page.SetBurning(localX, localY, 1); + page.SetBurnTime(localX, localY, MathF.Max(1f, particle.BurnDuration)); + return false; + } + + if (page.GetIntegrity(localX, localY) <= 0f) + { + if (!particle.IsStatic && particle.Kind == ParticleKind.Solid) + { + RemoveParticle(x, y); + return true; + } + + return false; + } + + return false; + } + + private float GetLocalPressure(int x, int y) => MathF.Abs(SamplePressure(x, y)); + + private float GetPressureLoad(int x, int y) + { + var local = GetLocalPressure(x, y); + var neighborLoad = + (MathF.Abs(SamplePressure(x - 1, y)) + + MathF.Abs(SamplePressure(x + 1, y)) + + MathF.Abs(SamplePressure(x, y - 1)) + + MathF.Abs(SamplePressure(x, y + 1))) * 0.5f; + return Math.Max(local, neighborLoad); + } + + private float GetFieldForceMagnitude(int x, int y) + { + var wind = GetWindAtCell(x, y); + var force = GetForceAtCell(x, y); + var totalX = wind.X + force.X; + var totalY = wind.Y + force.Y; + return MathF.Sqrt((totalX * totalX) + (totalY * totalY)); + } + + private float SamplePressure(int x, int y) + { + if (!TryGetFieldPage(x, y, out _, out var page, out var localX, out var localY)) + { + return 0f; + } + + return page.GetCell(localX, localY).Pressure; + } + + private float GetPressureDurationAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return 0f; + } + + return page.GetPressureDuration(localX, localY); + } + + private float GetIntegrityAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return 0f; + } + + return page.GetIntegrity(localX, localY); + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs new file mode 100644 index 0000000..d80f8f7 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Reactions.cs @@ -0,0 +1,174 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private bool ApplyLocalReaction(int x, int y, PrototypeParticle particle, ref int seed) + { + Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]; + for (var i = 0; i < neighbors.Length; i++) + { + var (nx, ny) = neighbors[i]; + if (!TryGetParticle(nx, ny, out var neighbor)) + { + continue; + } + + if (particle.HasFlag(PrototypeParticleFlags.WaterLike) && neighbor.HydrateTargetTypeId != 0 && TryResolveProfile(neighbor.HydrateTargetTypeId, out var hydrated)) + { + ReplaceParticle(nx, ny, hydrated); + RemoveParticle(x, y); + return true; + } + + if (particle.HasFlag(PrototypeParticleFlags.FireLike) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike)) + { + RemoveParticle(x, y); + if (neighbor.EvaporateTypeId != 0 && TryResolveProfile(neighbor.EvaporateTypeId, out var steam)) + { + ReplaceParticle(nx, ny, steam); + } + + return true; + } + + if (particle.HasFlag(PrototypeParticleFlags.HotSource) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike)) + { + if (particle.SolidifyTypeId != 0 && TryResolveProfile(particle.SolidifyTypeId, out var solidifiedSelf)) + { + ReplaceParticle(x, y, solidifiedSelf); + } + + if (neighbor.EvaporateTypeId != 0 && TryResolveProfile(neighbor.EvaporateTypeId, out var vaporizedNeighbor)) + { + ReplaceParticle(nx, ny, vaporizedNeighbor); + } + else + { + RemoveParticle(nx, ny); + } + + return true; + } + + if (particle.HasFlag(PrototypeParticleFlags.Acidic) && + !neighbor.IsStatic && + !neighbor.HasFlag(PrototypeParticleFlags.Acidic) && + NextChance(ref seed) <= 0.08f) + { + RemoveParticle(nx, ny); + if (NextChance(ref seed) <= 0.18f) + { + RemoveParticle(x, y); + return true; + } + } + } + + return false; + } + + private void ApplyPhaseTransition(int x, int y, ref PrototypeParticle particle) + { + var localTemperature = GetTemperatureAt(x, y); + var localPressure = GetPressureLoad(x, y); + var fieldForce = GetFieldForceMagnitude(x, y); + if (fieldForce > 0f) + { + localPressure += fieldForce * 0.2f; + } + + var cellAge = GetCellAgeAt(x, y); + if (particle.PhaseTransitionHysteresis > 0f && cellAge < particle.PhaseTransitionHysteresis) + { + return; + } + + if (particle.EvaporateTypeId != 0 && + localTemperature >= particle.EvaporateTemperature && + TryResolveProfile(particle.EvaporateTypeId, out var evaporated)) + { + ReplaceParticle(x, y, evaporated); + particle = evaporated; + return; + } + + if (particle.MeltTypeId != 0 && + localTemperature >= particle.MeltTemperature && + TryResolveProfile(particle.MeltTypeId, out var melted)) + { + ReplaceParticle(x, y, melted); + particle = melted; + return; + } + + if (particle.FreezeTypeId != 0 && + localTemperature <= particle.FreezeTemperature && + TryResolveProfile(particle.FreezeTypeId, out var frozen)) + { + ReplaceParticle(x, y, frozen); + particle = frozen; + return; + } + + if (particle.SolidifyTypeId != 0 && + (localTemperature <= particle.SolidifyTemperature || + (particle.MotionType == PrototypeParticleType.Steam && localPressure >= MathF.Max(0.9f, particle.PressureThreshold * 0.35f))) && + TryResolveProfile(particle.SolidifyTypeId, out var condensed)) + { + ReplaceParticle(x, y, condensed); + if (TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + page.SetTemperature(localX, localY, MathF.Max(condensed.InitialTemperature, MathF.Min(localTemperature, 95f))); + } + + particle = condensed; + } + } + + private float GetCellAgeAt(int x, int y) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return 0f; + } + + return page.GetCellAge(localX, localY); + } + + private bool HasPotentialReactiveNeighbor(int x, int y, PrototypeParticle particle) + { + Span<(int X, int Y)> neighbors = [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]; + for (var i = 0; i < neighbors.Length; i++) + { + var (nx, ny) = neighbors[i]; + if (!TryGetParticle(nx, ny, out var neighbor)) + { + continue; + } + + if (particle.HasFlag(PrototypeParticleFlags.WaterLike) && neighbor.HydrateTargetTypeId != 0) + { + return true; + } + + if (particle.HydrateTargetTypeId != 0 && neighbor.HasFlag(PrototypeParticleFlags.WaterLike)) + { + return true; + } + + if ((particle.HasFlag(PrototypeParticleFlags.FireLike) || particle.HasFlag(PrototypeParticleFlags.HotSource)) && neighbor.HasFlag(PrototypeParticleFlags.WaterLike)) + { + return true; + } + + if (particle.HasFlag(PrototypeParticleFlags.Acidic) && !neighbor.IsStatic && !neighbor.HasFlag(PrototypeParticleFlags.Acidic)) + { + return true; + } + } + + return false; + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs new file mode 100644 index 0000000..2b21342 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Runtime.Thermal.cs @@ -0,0 +1,83 @@ +using Sand.Core; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private void ApplyHeatEmission(int x, int y, float amount) + { + AdjustTemperatureAt(x, y, amount * 0.08f); + AdjustTemperatureAt(x - 1, y, amount * 0.02f); + AdjustTemperatureAt(x + 1, y, amount * 0.02f); + AdjustTemperatureAt(x, y - 1, amount * 0.02f); + AdjustTemperatureAt(x, y + 1, amount * 0.02f); + } + + private void ApplyTemperatureDiffusion(int x, int y, PrototypeParticle particle) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + var localTemperature = page.GetTemperature(localX, localY); + var airDelta = localTemperature - _ambientTemperature; + if (MathF.Abs(airDelta) > 0.5f) + { + var minTransfer = Math.Min(airDelta * -0.5f, airDelta * 0.5f); + var maxTransfer = Math.Max(airDelta * -0.5f, airDelta * 0.5f); + var transfer = Math.Clamp( + airDelta * 0.05f * particle.AmbientCoolingMultiplier / MathF.Max(0.1f, particle.Conductivity <= 0f ? 1f : particle.Conductivity), + minTransfer, + maxTransfer); + page.SetTemperature(localX, localY, localTemperature - transfer); + localTemperature -= transfer; + } + + DiffuseBetween(x, y, x + 1, y, particle, localTemperature); + DiffuseBetween(x, y, x, y + 1, particle, localTemperature); + } + + private void AdjustTemperatureAt(int x, int y, float delta) + { + if (!TryGetCellPage(x, y, out _, out var page, out var localX, out var localY)) + { + return; + } + + page.SetTemperature(localX, localY, page.GetTemperature(localX, localY) + delta); + } + + private void DiffuseBetween(int x, int y, int nx, int ny, PrototypeParticle particle, float localTemperature) + { + if (!TryGetCellPage(x, y, out _, out var sourcePage, out var sourceLocalX, out var sourceLocalY) || + !TryGetCellPage(nx, ny, out _, out var targetPage, out var targetLocalX, out var targetLocalY)) + { + return; + } + + var neighborParticle = targetPage[targetLocalX, targetLocalY]; + if (neighborParticle.TypeId == 0) + { + return; + } + + var neighborTemperature = targetPage.GetTemperature(targetLocalX, targetLocalY); + var delta = localTemperature - neighborTemperature; + if (MathF.Abs(delta) <= 0.5f) + { + return; + } + + var sourceConductivity = MathF.Max(particle.Conductivity, 0.1f); + var targetConductivity = MathF.Max(neighborParticle.Conductivity, 0.1f); + var minTransfer = Math.Min(delta * -0.5f, delta * 0.5f); + var maxTransfer = Math.Max(delta * -0.5f, delta * 0.5f); + var transfer = Math.Clamp( + delta * ((sourceConductivity + targetConductivity) * 0.5f) * 0.1f * particle.NeighborHeatTransferMultiplier, + minTransfer, + maxTransfer); + sourcePage.SetTemperature(sourceLocalX, sourceLocalY, localTemperature - transfer); + targetPage.SetTemperature(targetLocalX, targetLocalY, neighborTemperature + transfer); + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs new file mode 100644 index 0000000..d84efb3 --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs @@ -0,0 +1,472 @@ +using Sand.Core; +using System.Diagnostics; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private const int GasBandHaloRows = 2; + private const int FallingBandHaloRows = 1; + + public int StepDown() => Step(); + + public int Step() + { + _stepCounter++; + ResetStepMetrics(); + + if (_particleCount == 0) + { + var emptyFieldStart = Stopwatch.GetTimestamp(); + DecayFields(); + var emptyFieldDecayMicros = ToMicroseconds(emptyFieldStart, Stopwatch.GetTimestamp()); + _lastStepStats = new ChunkStepStats( + SteppedChunks: 0, + SleepingChunks: 0, + FieldPages: _fieldPages.Count, + MoveAttempts: 0, + VerticalMoveAttempts: 0, + DiagonalMoveAttempts: 0, + LateralMoveAttempts: 0, + SuccessfulMoves: 0, + SwapAttempts: 0, + StalledMovableCells: 0, + MovementOnlyFastPathCount: 0, + FullRuntimeStepCount: 0, + FullRuntimeSolidCount: 0, + FullRuntimeLiquidCount: 0, + FullRuntimeGasCount: 0, + MovedParticles: 0, + SwappedParticles: 0, + VisualDirtyPages: _dirtyVisualChunks.Count, + FrameBuildBytesTouched: _lastStepStats.FrameBuildBytesTouched, + ActivationTimeMicroseconds: 0, + MovementTimeMicroseconds: 0, + RuntimeTimeMicroseconds: 0, + FieldDecayTimeMicroseconds: emptyFieldDecayMicros, + RenderTimeMicroseconds: _lastStepStats.RenderTimeMicroseconds); + return 0; + } + + var activationStart = Stopwatch.GetTimestamp(); + var (activeChunks, sleepingChunks) = _scheduler.BuildSchedule(_cellPages); + var steppedChunks = 0; + for (var i = 0; i < activeChunks.Count; i++) + { + var coord = activeChunks[i]; + if (!_cellPages.TryGetValue(coord, out var page)) + { + continue; + } + + page.HasFieldActivity = false; + page.IsActive = false; + } + var activationMicros = ToMicroseconds(activationStart, Stopwatch.GetTimestamp()); + long movementMicros = 0; + long runtimeMicros = 0; + + ProcessGasChunksInterleaved(activeChunks, ref movementMicros, ref runtimeMicros); + + for (var i = 0; i < activeChunks.Count; i++) + { + var coord = activeChunks[i]; + if (!_cellPages.TryGetValue(coord, out var page)) + { + continue; + } + + if (!TryGetRowRange(page, FallingBandHaloRows, out var minRow, out var maxRow)) + { + page.ClearDirtyBands(); + page.DecayPendingSteps(); + page.IsActive = page.HasFieldActivity || page.PendingActiveSteps > 0; + steppedChunks++; + continue; + } + + page.ClearDirtyBands(); + ProcessFallingPage(coord, page, minRow, maxRow, ref movementMicros, ref runtimeMicros); + if (page.LastTouchedFrame == _stepCounter || page.HasFieldActivity) + { + page.IsActive = true; + } + else + { + page.DecayPendingSteps(); + page.IsActive = page.PendingActiveSteps > 0; + } + + steppedChunks++; + } + + ResolveGasChunkRowSeams(activeChunks); + + var fieldStart = Stopwatch.GetTimestamp(); + DecayFields(); + var fieldDecayMicros = ToMicroseconds(fieldStart, Stopwatch.GetTimestamp()); + _lastStepStats = new ChunkStepStats( + SteppedChunks: steppedChunks, + SleepingChunks: sleepingChunks, + FieldPages: _fieldPages.Count, + MoveAttempts: _moveAttemptCount, + VerticalMoveAttempts: _verticalMoveAttemptCount, + DiagonalMoveAttempts: _diagonalMoveAttemptCount, + LateralMoveAttempts: _lateralMoveAttemptCount, + SuccessfulMoves: _movedParticles, + SwapAttempts: _swapAttemptCount, + StalledMovableCells: _stalledMovableCount, + MovementOnlyFastPathCount: _movementOnlyFastPathCount, + FullRuntimeStepCount: _fullRuntimeStepCount, + FullRuntimeSolidCount: _fullRuntimeSolidCount, + FullRuntimeLiquidCount: _fullRuntimeLiquidCount, + FullRuntimeGasCount: _fullRuntimeGasCount, + MovedParticles: _movedParticles, + SwappedParticles: _swappedParticles, + VisualDirtyPages: _dirtyVisualChunks.Count, + FrameBuildBytesTouched: _lastStepStats.FrameBuildBytesTouched, + ActivationTimeMicroseconds: activationMicros, + MovementTimeMicroseconds: movementMicros, + RuntimeTimeMicroseconds: runtimeMicros, + FieldDecayTimeMicroseconds: fieldDecayMicros, + RenderTimeMicroseconds: _lastStepStats.RenderTimeMicroseconds); + return _movedParticles + _swappedParticles; + } + + private void ProcessGasChunksInterleaved(IReadOnlyList activeChunks, ref long movementMicros, ref long runtimeMicros) + { + if (activeChunks.Count == 0) + { + return; + } + + var minChunkY = int.MaxValue; + var maxChunkY = int.MinValue; + for (var i = 0; i < activeChunks.Count; i++) + { + var coord = activeChunks[i]; + if (!_cellPages.TryGetValue(coord, out var page) || page.GasCellCount == 0) + { + continue; + } + + minChunkY = Math.Min(minChunkY, coord.Y); + maxChunkY = Math.Max(maxChunkY, coord.Y); + } + + if (minChunkY == int.MaxValue) + { + return; + } + + for (var chunkY = minChunkY; chunkY <= maxChunkY; chunkY++) + { + for (var localY = 0; localY < _config.ChunkHeight; localY++) + { + var leftToRight = (((chunkY * _config.ChunkHeight) + localY + _stepCounter) & 1) == 0; + if (leftToRight) + { + for (var i = 0; i < activeChunks.Count; i++) + { + ProcessGasRowIfNeeded(activeChunks[i], chunkY, localY, ref movementMicros, ref runtimeMicros); + } + + continue; + } + + for (var i = activeChunks.Count - 1; i >= 0; i--) + { + ProcessGasRowIfNeeded(activeChunks[i], chunkY, localY, ref movementMicros, ref runtimeMicros); + } + } + } + } + + private void ProcessGasRowIfNeeded(ChunkCoord coord, int chunkY, int localY, ref long movementMicros, ref long runtimeMicros) + { + if (coord.Y != chunkY || !_cellPages.TryGetValue(coord, out var page) || page.GasCellCount == 0) + { + return; + } + + if (page.GetGasRowCount(localY) == 0) + { + return; + } + + if (!ShouldProcessGasRow(page, localY)) + { + return; + } + + ProcessGasRow(coord, page, localY, ref movementMicros, ref runtimeMicros); + } + + private void ProcessGasRow(ChunkCoord coord, ChunkCellPage page, int localY, ref long movementMicros, ref long runtimeMicros) + { + var movementOnlyPage = page.RuntimeCellCount == 0; + var moveCountBefore = _movedParticles + _swappedParticles; + var moveAttemptsBefore = _moveAttemptCount; + var rowGasCountAtStart = page.GetGasRowCount(localY); + var start = Stopwatch.GetTimestamp(); + for (var localX = 0; localX < page.Width; localX++) + { + if (page.IsProcessed(localX, localY, _stepCounter)) + { + continue; + } + + var particle = page[localX, localY]; + if (particle.MotionType != PrototypeParticleType.Steam) + { + continue; + } + + page.MarkProcessed(localX, localY, _stepCounter); + if (movementOnlyPage || !particle.RequiresFullRuntimeStep) + { + StepMovementOnlyParticle(coord, localX, localY, particle); + continue; + } + + TryStepParticle(coord, localX, localY, particle); + } + + if (movementOnlyPage) + { + UpdateSparseGasRowCooldown(page, localY, rowGasCountAtStart, (_movedParticles + _swappedParticles) - moveCountBefore, _moveAttemptCount - moveAttemptsBefore); + } + else + { + page.ClearGasRowCooldown(localY); + } + + var elapsedMicros = ToMicroseconds(start, Stopwatch.GetTimestamp()); + if (movementOnlyPage) + { + movementMicros += elapsedMicros; + } + else + { + runtimeMicros += elapsedMicros; + } + } + + private void ResolveGasChunkRowSeams(IReadOnlyList activeChunks) + { + for (var i = activeChunks.Count - 1; i >= 0; i--) + { + var lowerCoord = activeChunks[i]; + if (!_cellPages.TryGetValue(lowerCoord, out var lowerPage)) + { + continue; + } + + for (var localX = 0; localX < lowerPage.Width; localX++) + { + var particle = lowerPage[localX, 0]; + if (particle.MotionType != PrototypeParticleType.Steam) + { + continue; + } + + var worldX = ToWorldX(lowerCoord, localX); + var worldY = ToWorldY(lowerCoord, 0); + if (!InBounds(worldX, worldY - 1)) + { + continue; + } + + if (TryMoveEmpty(worldX, worldY, worldX, worldY - 1)) + { + CompactGasSeamColumn(worldX, worldY); + } + } + } + } + + private void CompactGasSeamColumn(int x, int vacancyY) + { + while (InBounds(x, vacancyY + 1) && !HasParticle(x, vacancyY)) + { + if (!TryGetParticle(x, vacancyY + 1, out var below) || below.MotionType != PrototypeParticleType.Steam) + { + break; + } + + if (!MoveParticle(x, vacancyY + 1, x, vacancyY)) + { + break; + } + + vacancyY++; + } + } + + private void ProcessFallingPage(ChunkCoord coord, ChunkCellPage page, int minRow, int maxRow, ref long movementMicros, ref long runtimeMicros) + { + var movementOnlyPage = page.RuntimeCellCount == 0; + var start = Stopwatch.GetTimestamp(); + for (var localY = maxRow; localY >= minRow; localY--) + { + if (page.GetOccupiedRowCount(localY) == 0) + { + continue; + } + + for (var localX = 0; localX < page.Width; localX++) + { + if (page.IsProcessed(localX, localY, _stepCounter)) + { + continue; + } + + var particle = page[localX, localY]; + if (particle.TypeId == 0 || particle.MotionType == PrototypeParticleType.Steam) + { + continue; + } + + page.MarkProcessed(localX, localY, _stepCounter); + if (movementOnlyPage || !particle.RequiresFullRuntimeStep) + { + StepMovementOnlyParticle(coord, localX, localY, particle); + continue; + } + + TryStepParticle(coord, localX, localY, particle); + } + } + + var elapsedMicros = ToMicroseconds(start, Stopwatch.GetTimestamp()); + if (movementOnlyPage) + { + movementMicros += elapsedMicros; + } + else + { + runtimeMicros += elapsedMicros; + } + } + + private bool ShouldProcessGasRow(ChunkCellPage page, int localY) + { + if (page.HasFieldActivity) + { + page.ClearGasRowCooldown(localY); + return true; + } + + if (page.RuntimeCellCount > 0) + { + page.ClearGasRowCooldown(localY); + return true; + } + + if (ShouldSkipSparseGasRow(page, localY)) + { + return false; + } + + if (!page.HasDirtyBands) + { + return false; + } + + var minRow = Math.Max(0, page.DirtyMinRow - GasBandHaloRows); + var maxRow = Math.Min(page.Height - 1, page.DirtyMaxRow + GasBandHaloRows); + return localY >= minRow && localY <= maxRow; + } + + private bool ShouldSkipSparseGasRow(ChunkCellPage page, int localY) + { + var signature = ComputeSparseGasRowSignature(page, localY); + if (signature == 0) + { + page.ClearGasRowCooldown(localY); + return false; + } + + if (page.GetGasRowCooldownUntilStep(localY) > _stepCounter && page.GetGasRowCooldownSignature(localY) == signature) + { + return true; + } + + if (page.GetGasRowCooldownSignature(localY) != signature) + { + page.ClearGasRowCooldown(localY); + } + + return false; + } + + private void UpdateSparseGasRowCooldown(ChunkCellPage page, int localY, int rowGasCountAtStart, int moveDelta, int attemptDelta) + { + if (rowGasCountAtStart <= 0 || rowGasCountAtStart > 4) + { + page.ClearGasRowCooldown(localY); + return; + } + + if (moveDelta != 0 || attemptDelta <= 0) + { + page.ClearGasRowCooldown(localY); + return; + } + + var signature = ComputeSparseGasRowSignature(page, localY); + if (signature == 0) + { + page.ClearGasRowCooldown(localY); + return; + } + + var cooldownFrames = attemptDelta > rowGasCountAtStart ? 2 : 1; + page.SetGasRowCooldown(localY, signature, _stepCounter + cooldownFrames); + } + + private static int ComputeSparseGasRowSignature(ChunkCellPage page, int localY) + { + var gasCount = page.GetGasRowCount(localY); + if (gasCount <= 0 || gasCount > 4) + { + return 0; + } + + var aboveRow = Math.Max(0, localY - 1); + return HashCode.Combine( + localY, + page.GetRowRevision(localY), + page.GetRowRevision(aboveRow), + page.GetGasRowCount(localY), + page.GetGasRowCount(aboveRow)); + } + + private static bool TryGetRowRange(ChunkCellPage page, int haloRows, out int minRow, out int maxRow) + { + if (page.HasFieldActivity) + { + minRow = 0; + maxRow = page.Height - 1; + return true; + } + + if (!page.HasDirtyBands) + { + if (page.RuntimeCellCount > 0) + { + minRow = 0; + maxRow = page.Height - 1; + return true; + } + + minRow = 0; + maxRow = -1; + return false; + } + + minRow = Math.Max(0, page.DirtyMinRow - haloRows); + maxRow = Math.Min(page.Height - 1, page.DirtyMaxRow + haloRows); + return minRow <= maxRow; + } +} diff --git a/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs new file mode 100644 index 0000000..3a769fd --- /dev/null +++ b/Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs @@ -0,0 +1,183 @@ +using Sand.Core; +using System.Diagnostics; + +namespace Sand.ChunkPrototype; + +public sealed partial class PrototypeSparseSandAdapter +{ + private const float FieldDecayFactor = 0.84f; + private const int RenderHaloCells = 1; + private readonly ChunkResidencyConfig _config; + private readonly Dictionary _particleProfiles = new(); + private readonly Dictionary _cellPages = new(); + private readonly Dictionary _fieldPages = new(); + private readonly HashSet _dirtyVisualChunks = new(); + private readonly ChunkStepScheduler _scheduler = new(); + private readonly byte[] _rgbaBuffer; + private readonly byte[] _rgbBuffer; + private readonly float _ambientTemperature; + private bool _fullFrameDirty = true; + private int _stepCounter; + private int _particleCount; + private int _moveAttemptCount; + private int _verticalMoveAttemptCount; + private int _diagonalMoveAttemptCount; + private int _lateralMoveAttemptCount; + private int _swapAttemptCount; + private int _stalledMovableCount; + private int _movementOnlyFastPathCount; + private int _fullRuntimeStepCount; + private int _fullRuntimeSolidCount; + private int _fullRuntimeLiquidCount; + private int _fullRuntimeGasCount; + private int _movedParticles; + private int _swappedParticles; + private ChunkStepStats _lastStepStats; + + public PrototypeSparseSandAdapter(int width, int height) + : this(width, height, 22f, null) + { + } + + public PrototypeSparseSandAdapter(int width, int height, ChunkResidencyConfig config) + : this(width, height, 22f, config) + { + } + + public PrototypeSparseSandAdapter(int width, int height, float ambientTemperature) + : this(width, height, ambientTemperature, null) + { + } + + public PrototypeSparseSandAdapter(int width, int height, float ambientTemperature, ChunkResidencyConfig? config) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width)); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + Width = width; + Height = height; + _ambientTemperature = ambientTemperature; + _config = config ?? ChunkResidencyConfig.Default; + World = new PrototypeChunkResidencyWorld(_config); + _rgbaBuffer = new byte[width * height * 4]; + _rgbBuffer = new byte[width * height * 3]; + RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Sand)); + RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Water)); + RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Steam)); + RegisterParticleProfile(CreateLegacyParticle(PrototypeParticleType.Wall)); + } + + public int Width { get; } + public int Height { get; } + public PrototypeChunkResidencyWorld World { get; } + public int ParticleCount => _particleCount; + public ChunkStepStats LastStepStats => _lastStepStats; + public int FieldCellCount => _fieldPages.Values.Sum(static page => page.ActiveCellCount); + public long EstimatedStorageBytes => + World.EstimatedLoadedBytes + + ((long)_cellPages.Count * _config.ChunkWidth * _config.ChunkHeight * 16L) + + ((long)_fieldPages.Count * _config.ChunkWidth * _config.ChunkHeight * 20L); + + public IReadOnlyCollection<(int X, int Y)> Particles + { + get + { + var particles = new List<(int X, int Y)>(_particleCount); + foreach (var (coord, page) in _cellPages) + { + foreach (var (localX, localY, _) in page.EnumerateOccupied()) + { + particles.Add((ToWorldX(coord, localX), ToWorldY(coord, localY))); + } + } + + return particles; + } + } + + public IReadOnlyCollection> ParticleEntries + { + get + { + var particles = new List>(_particleCount); + foreach (var (coord, page) in _cellPages) + { + foreach (var (localX, localY, particle) in page.EnumerateOccupied()) + { + particles.Add(new KeyValuePair<(int X, int Y), PrototypeParticle>((ToWorldX(coord, localX), ToWorldY(coord, localY)), particle)); + } + } + + return particles; + } + } + + public bool AddParticle(int x, int y, PrototypeParticleType type = PrototypeParticleType.Sand) => AddParticle(x, y, CreateLegacyParticle(type)); + + public void RegisterParticleProfile(PrototypeParticle particle) + { + if (!particle.IsEmpty) + { + _particleProfiles[particle.TypeId] = particle; + } + } + + private static long ToMicroseconds(long startTimestamp, long endTimestamp) + { + if (endTimestamp <= startTimestamp) + { + return 0; + } + + return (endTimestamp - startTimestamp) * 1_000_000L / Stopwatch.Frequency; + } + + private void ResetStepMetrics() + { + _moveAttemptCount = 0; + _verticalMoveAttemptCount = 0; + _diagonalMoveAttemptCount = 0; + _lateralMoveAttemptCount = 0; + _swapAttemptCount = 0; + _stalledMovableCount = 0; + _movementOnlyFastPathCount = 0; + _fullRuntimeStepCount = 0; + _fullRuntimeSolidCount = 0; + _fullRuntimeLiquidCount = 0; + _fullRuntimeGasCount = 0; + _movedParticles = 0; + _swappedParticles = 0; + } + + private void RecordMoveAttempt(int deltaX, int deltaY) + { + _moveAttemptCount++; + if (deltaY == 0) + { + _lateralMoveAttemptCount++; + return; + } + + if (deltaX == 0) + { + _verticalMoveAttemptCount++; + return; + } + + _diagonalMoveAttemptCount++; + } + + private void RecordSwapAttempt() => _swapAttemptCount++; + + private void RecordStalledMovable() + { + _stalledMovableCount++; + } +} diff --git a/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj b/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj new file mode 100644 index 0000000..dd2bf73 --- /dev/null +++ b/Sand.ChunkPrototype/Sand.ChunkPrototype.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + + + + + + + diff --git a/Sand.Core/IParticleLibrary.cs b/Sand.Core/IParticleLibrary.cs new file mode 100644 index 0000000..387062b --- /dev/null +++ b/Sand.Core/IParticleLibrary.cs @@ -0,0 +1,8 @@ +namespace Sand.Core; + +public interface IParticleLibrary +{ + IReadOnlyList Definitions { get; } + ushort GetTypeId(string id); + ParticleDef GetDefinition(ushort typeId); +} diff --git a/Sand.Core/ISimulationAccelerator.cs b/Sand.Core/ISimulationAccelerator.cs new file mode 100644 index 0000000..d0e2cce --- /dev/null +++ b/Sand.Core/ISimulationAccelerator.cs @@ -0,0 +1,6 @@ +namespace Sand.Core; + +public interface ISimulationAccelerator +{ + void Step(SandSimulation simulation, float dt); +} diff --git a/Sand.Core/ParticleBalanceProfile.cs b/Sand.Core/ParticleBalanceProfile.cs new file mode 100644 index 0000000..ddf2bbf --- /dev/null +++ b/Sand.Core/ParticleBalanceProfile.cs @@ -0,0 +1,27 @@ +namespace Sand.Core; + +public sealed class ParticleBalanceProfile +{ + public string Id { get; init; } = ""; + public ParticleBehaviorKind BehaviorKind { get; init; } + public float LifetimeMultiplier { get; init; } = 1f; + public float BurnDecayPerStep { get; init; } = 1f; + public float AmbientCoolingMultiplier { get; init; } = 1f; + public float NeighborHeatTransferMultiplier { get; init; } = 1f; + public float UpwardBias { get; init; } + public float SideDriftBias { get; init; } + public float FireSpreadChance { get; init; } + public float SmokeSpawnChance { get; init; } + public float EmberSpawnChance { get; init; } + public float ProduceChance { get; init; } + public float HeatEmissionMultiplier { get; init; } = 1f; + public float EnergyTransferMultiplier { get; init; } = 1f; + public float PressureSensitivity { get; init; } = 1f; + public float PressureDecayMultiplier { get; init; } = 1f; + public float ForceResponseMultiplier { get; init; } = 1f; + public float LateralFlowMultiplier { get; init; } = 1f; + public float DiagonalFlowMultiplier { get; init; } = 1f; + public float PhaseTransitionHysteresis { get; init; } + public float MinLifetimeTicks { get; init; } + public float MaxLifetimeTicks { get; init; } +} diff --git a/Sand.Core/ParticleBehaviorKind.cs b/Sand.Core/ParticleBehaviorKind.cs new file mode 100644 index 0000000..f9352c9 --- /dev/null +++ b/Sand.Core/ParticleBehaviorKind.cs @@ -0,0 +1,10 @@ +namespace Sand.Core; + +public enum ParticleBehaviorKind : byte +{ + None = 0, + Fire = 1, + BurningWood = 2, + Ember = 3, + Plasma = 4, +} diff --git a/Sand.Core/ParticleDef.cs b/Sand.Core/ParticleDef.cs new file mode 100644 index 0000000..243f8c5 --- /dev/null +++ b/Sand.Core/ParticleDef.cs @@ -0,0 +1,63 @@ +namespace Sand.Core; + +public sealed class ParticleDef +{ + public string Id { get; init; } = ""; + public string Name { get; init; } = ""; + public ParticleKind Kind { get; init; } = ParticleKind.Solid; + public bool IsStatic { get; init; } + public bool IsSpecial { get; init; } + public float Mass { get; init; } = 1f; + public float Hardness { get; init; } = 0.5f; + public float Velocity { get; init; } + public float Conductivity { get; init; } + public bool Conductive { get; init; } + public float Flamability { get; init; } + public float Durability { get; init; } = 100f; + public float HeatCapacity { get; init; } = 1f; + public float Friction { get; init; } + public float Viscosity { get; init; } + public float Pressure { get; init; } + public float Temperature { get; init; } = 22f; + public string? Melt { get; init; } + public float? MeltTemperature { get; init; } + public string? Evaporate { get; init; } + public float? EvaporateTemperature { get; init; } + public string? Solidify { get; init; } + public float? SolidifyTemperature { get; init; } + public string? Freeze { get; init; } + public float? FreezeTemperature { get; init; } + public float BurnDuration { get; init; } + public float BurnTemperature { get; init; } + public float BurnRate { get; init; } = 1f; + public bool Burning { get; init; } + public float? Lifetime { get; init; } + public bool Explosive { get; init; } + public int ExplosionRadius { get; init; } + public Rgb24? ExplosionColor { get; init; } + public float ExplosionForce { get; init; } + public int ExplosionDuration { get; init; } + public float PressureResistance { get; init; } + public float PressureTolerance { get; init; } + public float PressureThreshold { get; init; } + public int PressureThresholdDuration { get; init; } + public string? Broken { get; init; } + public string? Produces { get; init; } + public string? ProducesOnDeath { get; init; } + public float HeatEmission { get; init; } + public float EnergyTransfer { get; init; } + public float Radius { get; init; } + public float ForceFalloff { get; init; } + public float Turbulence { get; init; } + public string[] Affects { get; init; } = []; + public bool IsWind { get; init; } + public float WindStrength { get; init; } + public float[]? WindDirection { get; init; } + public bool IsGravity { get; init; } + public float GravityStrength { get; init; } + public string? PullDirection { get; init; } + public bool IsRepulsor { get; init; } + public float RepulsionStrength { get; init; } + public string? PushDirection { get; init; } + public Rgb24 Color { get; init; } = new(255, 255, 255); +} diff --git a/Sand.Core/ParticleKind.cs b/Sand.Core/ParticleKind.cs new file mode 100644 index 0000000..ce07265 --- /dev/null +++ b/Sand.Core/ParticleKind.cs @@ -0,0 +1,8 @@ +namespace Sand.Core; + +public enum ParticleKind : byte +{ + Solid = 1, + Liquid = 2, + Gas = 3, +} diff --git a/Sand.Core/ParticleLibrary.cs b/Sand.Core/ParticleLibrary.cs new file mode 100644 index 0000000..7b47f58 --- /dev/null +++ b/Sand.Core/ParticleLibrary.cs @@ -0,0 +1,49 @@ +using System.Collections.ObjectModel; + +namespace Sand.Core; + +public sealed class ParticleLibrary : IParticleLibrary +{ + private readonly IReadOnlyList _definitions; + private readonly Dictionary _typeIds; + + public ParticleLibrary(IReadOnlyList definitions) + { + if (definitions.Count == 0) + { + throw new ArgumentException("At least one particle definition is required.", nameof(definitions)); + } + + _definitions = new ReadOnlyCollection(definitions.ToArray()); + _typeIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + for (var index = 0; index < definitions.Count; index++) + { + _typeIds[definitions[index].Id] = checked((ushort)(index + 1)); + } + } + + public IReadOnlyList Definitions => _definitions; + + public ushort GetTypeId(string id) + { + if (_typeIds.TryGetValue(id, out var typeId)) + { + return typeId; + } + + throw new KeyNotFoundException($"Unknown particle id '{id}'."); + } + + public bool TryGetTypeId(string id, out ushort typeId) => _typeIds.TryGetValue(id, out typeId); + + public ParticleDef GetDefinition(ushort typeId) + { + if (typeId == 0 || typeId > _definitions.Count) + { + throw new ArgumentOutOfRangeException(nameof(typeId), $"Unknown type id '{typeId}'."); + } + + return _definitions[typeId - 1]; + } +} diff --git a/Sand.Core/ParticleLibraryLoader.cs b/Sand.Core/ParticleLibraryLoader.cs new file mode 100644 index 0000000..57f1a95 --- /dev/null +++ b/Sand.Core/ParticleLibraryLoader.cs @@ -0,0 +1,255 @@ +using System.Text.Json.Nodes; + +namespace Sand.Core; + +public static class ParticleLibraryLoader +{ + private static readonly HashSet StaticTypes = + [ + "wall", + "stone", + "rock", + "iron", + "gold", + "copper", + "wood", + "brass", + "glass", + ]; + + public static ParticleLibrary LoadFromDirectory(string partRootPath) + { + var directories = new[] + { + Path.Combine(partRootPath, "coreparts"), + Path.Combine(partRootPath, "mods"), + }; + + var rawDefinitions = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var directory in directories) + { + if (!Directory.Exists(directory)) + { + continue; + } + + foreach (var filePath in Directory.EnumerateFiles(directory, "*.json", SearchOption.AllDirectories)) + { + var rootNode = JsonNode.Parse(File.ReadAllText(filePath)) as JsonObject; + if (rootNode is null) + { + continue; + } + + foreach (var pair in rootNode) + { + if (pair.Value is JsonObject obj) + { + rawDefinitions[pair.Key.ToLowerInvariant()] = obj; + } + } + } + } + + var orderedIds = rawDefinitions.Keys.OrderBy(static id => id, StringComparer.Ordinal).ToArray(); + var definitions = new List(orderedIds.Length); + + foreach (var id in orderedIds) + { + definitions.Add(ParseDefinition(id, rawDefinitions[id])); + } + + var knownIds = new HashSet(orderedIds, StringComparer.OrdinalIgnoreCase); + foreach (var definition in definitions) + { + ValidateTarget(knownIds, definition.Id, definition.Melt, nameof(definition.Melt)); + ValidateTarget(knownIds, definition.Id, definition.Evaporate, nameof(definition.Evaporate)); + ValidateTarget(knownIds, definition.Id, definition.Solidify, nameof(definition.Solidify)); + ValidateTarget(knownIds, definition.Id, definition.Freeze, nameof(definition.Freeze)); + ValidateTarget(knownIds, definition.Id, definition.Broken, nameof(definition.Broken)); + ValidateTarget(knownIds, definition.Id, definition.Produces, nameof(definition.Produces)); + ValidateTarget(knownIds, definition.Id, definition.ProducesOnDeath, nameof(definition.ProducesOnDeath)); + } + + return new ParticleLibrary(definitions); + } + + private static ParticleDef ParseDefinition(string id, JsonObject source) + { + var isGas = GetBool(source, "is_gas"); + var isLiquid = GetBool(source, "liquid"); + var isSolid = GetBool(source, "solid", true); + var isSpecial = !isGas && !isLiquid && !isSolid; + + return new ParticleDef + { + Id = id, + Name = GetString(source, "name") ?? id, + Kind = isGas ? ParticleKind.Gas : isLiquid ? ParticleKind.Liquid : ParticleKind.Solid, + IsStatic = StaticTypes.Contains(id) || GetBool(source, "static"), + IsSpecial = isSpecial, + Mass = GetFloat(source, "mass", 1f), + Hardness = GetFloat(source, "hardness", 0.5f), + Velocity = GetFloat(source, "velocity", 0f), + Conductivity = GetFloat(source, "conductivity", 0f), + Conductive = GetBool(source, "conductive"), + Flamability = GetFloat(source, "flamability", 0f), + Durability = GetFloat(source, "durability", 100f), + HeatCapacity = GetFloat(source, "heat_capacity", 1f), + Friction = GetFloat(source, "friction", 0f), + Viscosity = GetFloat(source, "viscosity", 0f), + Pressure = GetFloat(source, "pressure", 0f), + Temperature = GetFloat(source, "temperature", 22f), + Melt = GetLowerString(source, "melt"), + MeltTemperature = GetNullableFloat(source, "melt_temperature"), + Evaporate = GetLowerString(source, "evaporate"), + EvaporateTemperature = GetNullableFloat(source, "evaporate_temperature"), + Solidify = GetLowerString(source, "solidify"), + SolidifyTemperature = GetNullableFloat(source, "solidify_temperature"), + Freeze = GetLowerString(source, "freeze"), + FreezeTemperature = GetNullableFloat(source, "freeze_temperature"), + BurnDuration = GetFloat(source, "burn_duration", 0f), + BurnTemperature = GetFloat(source, "burn_temperature", 0f), + BurnRate = GetFloat(source, "burn_rate", 1f), + Burning = GetBool(source, "burning"), + Lifetime = GetNullableFloat(source, "lifetime"), + Explosive = GetBool(source, "explosive"), + ExplosionRadius = GetInt(source, "explosion_radius", 0), + ExplosionColor = GetOptionalColor(source, "explosion_color"), + ExplosionForce = GetFloat(source, "explosion_force", 6f), + ExplosionDuration = GetInt(source, "explosion_duration", 1), + PressureResistance = GetFloat(source, "pressure_resistance", 0f), + PressureTolerance = GetFloat(source, "pressure_tolerance", 0f), + PressureThreshold = GetFloat(source, "pressure_threshold", 0f), + PressureThresholdDuration = GetInt(source, "pressure_threshold_duration", 0), + Broken = GetLowerString(source, "Broken") ?? GetLowerString(source, "broken"), + Produces = GetLowerString(source, "produces"), + ProducesOnDeath = GetLowerString(source, "produces_on_death"), + HeatEmission = GetFloat(source, "heat_emission", 0f), + EnergyTransfer = GetFloat(source, "energy_transfer", 0f), + Radius = GetFloat(source, "radius", 0f), + ForceFalloff = GetFloat(source, "force_falloff", 1f), + Turbulence = GetFloat(source, "turbulence", 0f), + Affects = GetStringArray(source, "affects"), + IsWind = GetBool(source, "is_wind"), + WindStrength = GetFloat(source, "wind_strength", 0f), + WindDirection = GetFloatArray(source, "wind_direction"), + IsGravity = GetBool(source, "is_gravity"), + GravityStrength = GetFloat(source, "gravity_strength", 0f), + PullDirection = GetLowerString(source, "pull_direction"), + IsRepulsor = GetBool(source, "is_repulsor"), + RepulsionStrength = GetFloat(source, "repulsion_strength", 0f), + PushDirection = GetLowerString(source, "push_direction"), + Color = GetColor(source), + }; + } + + private static void ValidateTarget(HashSet knownIds, string id, string? target, string propertyName) + { + if (target is not null && !knownIds.Contains(target)) + { + throw new InvalidDataException($"Particle '{id}' references unknown {propertyName} target '{target}'."); + } + } + + private static string? GetString(JsonObject source, string key) => source[key]?.GetValue(); + + private static string? GetLowerString(JsonObject source, string key) + { + var value = GetString(source, key); + return value?.ToLowerInvariant(); + } + + private static bool GetBool(JsonObject source, string key, bool defaultValue = false) + { + if (source[key] is null) + { + return defaultValue; + } + + return source[key]!.GetValue(); + } + + private static float GetFloat(JsonObject source, string key, float defaultValue) + { + if (source[key] is null) + { + return defaultValue; + } + + return source[key]!.GetValue(); + } + + private static int GetInt(JsonObject source, string key, int defaultValue) + { + if (source[key] is null) + { + return defaultValue; + } + + return source[key]!.GetValue(); + } + + private static float? GetNullableFloat(JsonObject source, string key) + { + if (source[key] is null) + { + return null; + } + + return source[key]!.GetValue(); + } + + private static Rgb24 GetColor(JsonObject source) + { + if (source["color"] is not JsonArray array || array.Count < 3) + { + return new Rgb24(255, 255, 255); + } + + return new Rgb24( + (byte)array[0]!.GetValue(), + (byte)array[1]!.GetValue(), + (byte)array[2]!.GetValue()); + } + + private static Rgb24? GetOptionalColor(JsonObject source, string key) + { + if (source[key] is not JsonArray array || array.Count < 3) + { + return null; + } + + return new Rgb24( + (byte)array[0]!.GetValue(), + (byte)array[1]!.GetValue(), + (byte)array[2]!.GetValue()); + } + + private static string[] GetStringArray(JsonObject source, string key) + { + if (source[key] is not JsonArray array) + { + return []; + } + + return array + .Where(static item => item is not null) + .Select(static item => item!.GetValue().ToLowerInvariant()) + .ToArray(); + } + + private static float[]? GetFloatArray(JsonObject source, string key) + { + if (source[key] is not JsonArray array || array.Count == 0) + { + return null; + } + + return array + .Where(static item => item is not null) + .Select(static item => item!.GetValue()) + .ToArray(); + } +} diff --git a/Sand.Core/ParticleRuntimeProfile.cs b/Sand.Core/ParticleRuntimeProfile.cs new file mode 100644 index 0000000..a2d46ea --- /dev/null +++ b/Sand.Core/ParticleRuntimeProfile.cs @@ -0,0 +1,7 @@ +namespace Sand.Core; + +public sealed class ParticleRuntimeProfile +{ + public required ParticleDef Definition { get; init; } + public required ParticleBalanceProfile Balance { get; init; } +} diff --git a/Sand.Core/ParticleRuntimeProfileBuilder.cs b/Sand.Core/ParticleRuntimeProfileBuilder.cs new file mode 100644 index 0000000..496d8a0 --- /dev/null +++ b/Sand.Core/ParticleRuntimeProfileBuilder.cs @@ -0,0 +1,146 @@ +namespace Sand.Core; + +public static class ParticleRuntimeProfileBuilder +{ + public static ParticleRuntimeProfile Build(ParticleDef definition) + { + return new ParticleRuntimeProfile + { + Definition = definition, + Balance = BuildBalance(definition), + }; + } + + private static ParticleBalanceProfile BuildBalance(ParticleDef definition) + { + return definition.Id switch + { + "fire" => new ParticleBalanceProfile + { + Id = definition.Id, + BehaviorKind = ParticleBehaviorKind.Fire, + LifetimeMultiplier = 1f, + BurnDecayPerStep = 2.5f, + AmbientCoolingMultiplier = 0.2f, + NeighborHeatTransferMultiplier = 0.45f, + UpwardBias = 1f, + SideDriftBias = 0.45f, + FireSpreadChance = 0.14f, + SmokeSpawnChance = 0.18f, + EmberSpawnChance = 0.04f, + HeatEmissionMultiplier = 1.1f, + MinLifetimeTicks = 18f, + MaxLifetimeTicks = 40f, + PhaseTransitionHysteresis = 0f, + }, + "burning_wood" => new ParticleBalanceProfile + { + Id = definition.Id, + BehaviorKind = ParticleBehaviorKind.BurningWood, + BurnDecayPerStep = 0.65f, + AmbientCoolingMultiplier = 0.35f, + NeighborHeatTransferMultiplier = 0.75f, + SmokeSpawnChance = 0.14f, + EmberSpawnChance = 0.025f, + ProduceChance = 0.12f, + HeatEmissionMultiplier = 1f, + FireSpreadChance = 0.08f, + PressureSensitivity = 0.8f, + MinLifetimeTicks = 240f, + MaxLifetimeTicks = 420f, + }, + "ember" => new ParticleBalanceProfile + { + Id = definition.Id, + BehaviorKind = ParticleBehaviorKind.Ember, + LifetimeMultiplier = 1f, + BurnDecayPerStep = 1.5f, + AmbientCoolingMultiplier = 0.3f, + NeighborHeatTransferMultiplier = 0.65f, + UpwardBias = 0.35f, + SideDriftBias = 0.25f, + FireSpreadChance = 0.05f, + SmokeSpawnChance = 0.10f, + ProduceChance = 0.08f, + HeatEmissionMultiplier = 0.8f, + MinLifetimeTicks = 20f, + MaxLifetimeTicks = 45f, + }, + "plasma" => new ParticleBalanceProfile + { + Id = definition.Id, + BehaviorKind = ParticleBehaviorKind.Plasma, + AmbientCoolingMultiplier = 0.1f, + NeighborHeatTransferMultiplier = 0.5f, + UpwardBias = 0.95f, + SideDriftBias = 0.55f, + FireSpreadChance = 0.18f, + HeatEmissionMultiplier = 1.6f, + EnergyTransferMultiplier = 1.75f, + MinLifetimeTicks = 18f, + MaxLifetimeTicks = 60f, + }, + "lava" => new ParticleBalanceProfile + { + Id = definition.Id, + AmbientCoolingMultiplier = 0.05f, + NeighborHeatTransferMultiplier = 0.12f, + FireSpreadChance = 0.08f, + HeatEmissionMultiplier = 1.35f, + ForceResponseMultiplier = 0.22f, + LateralFlowMultiplier = 0.38f, + DiagonalFlowMultiplier = 0.6f, + PhaseTransitionHysteresis = 60f, + PressureSensitivity = 1.1f, + }, + var id when id.StartsWith("molten_", StringComparison.Ordinal) => new ParticleBalanceProfile + { + Id = definition.Id, + AmbientCoolingMultiplier = 0.08f, + NeighborHeatTransferMultiplier = 0.16f, + HeatEmissionMultiplier = 1.15f, + ForceResponseMultiplier = 0.32f, + LateralFlowMultiplier = 0.5f, + DiagonalFlowMultiplier = 0.72f, + PhaseTransitionHysteresis = 45f, + PressureSensitivity = 1.05f, + }, + "steam" => new ParticleBalanceProfile + { + Id = definition.Id, + AmbientCoolingMultiplier = 0.2f, + NeighborHeatTransferMultiplier = 0.45f, + PhaseTransitionHysteresis = 15f, + }, + "smoke" => new ParticleBalanceProfile + { + Id = definition.Id, + LifetimeMultiplier = 1.25f, + AmbientCoolingMultiplier = 0.25f, + UpwardBias = 0.4f, + SideDriftBias = 0.2f, + MinLifetimeTicks = 100f, + MaxLifetimeTicks = 140f, + }, + "ice" => new ParticleBalanceProfile + { + Id = definition.Id, + AmbientCoolingMultiplier = 0.15f, + NeighborHeatTransferMultiplier = 0.4f, + PhaseTransitionHysteresis = 24f, + }, + "snow" => new ParticleBalanceProfile + { + Id = definition.Id, + AmbientCoolingMultiplier = 0.22f, + NeighborHeatTransferMultiplier = 0.5f, + PhaseTransitionHysteresis = 18f, + }, + _ => new ParticleBalanceProfile + { + Id = definition.Id, + BurnDecayPerStep = 1f, + }, + }; + } +} diff --git a/Sand.Core/Rgb24.cs b/Sand.Core/Rgb24.cs new file mode 100644 index 0000000..33ca245 --- /dev/null +++ b/Sand.Core/Rgb24.cs @@ -0,0 +1,3 @@ +namespace Sand.Core; + +public readonly record struct Rgb24(byte R, byte G, byte B); diff --git a/Sand.Core/Sand.Core.csproj b/Sand.Core/Sand.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/Sand.Core/Sand.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Sand.Core/SandSimulation.CellsAndForces.cs b/Sand.Core/SandSimulation.CellsAndForces.cs new file mode 100644 index 0000000..b52b53a --- /dev/null +++ b/Sand.Core/SandSimulation.CellsAndForces.cs @@ -0,0 +1,544 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + private void SetCell(int x, int y, ushort typeId, bool markProcessed = true, bool clearDynamics = false) + { + var wasEmpty = TypeId[x, y] == 0; + if (wasEmpty) + { + ParticleCount++; + } + + TypeId[x, y] = typeId; + Temperature[x, y] = _initialTemperature[typeId]; + BurnTime[x, y] = _burnDuration[typeId]; + Burning[x, y] = _burningInit[typeId]; + SparkTime[x, y] = 0; + Lifetime[x, y] = _defaultLifetime[typeId]; + _pressureDuration[x, y] = 0f; + _cellAge[x, y] = 0f; + _integrity[x, y] = _durability[typeId]; + if (clearDynamics) + { + ClearDynamicFieldsAt(x, y); + } + ExpandOccupiedBoundsToInclude(x, y); + MarkVisualDirty(x, y); + if (markProcessed) + { + MarkProcessed(x, y); + } + } + + private void ForceSetCell(int x, int y, ushort typeId) + { + var wasEmpty = TypeId[x, y] == 0; + if (wasEmpty) + { + ParticleCount++; + } + + TypeId[x, y] = typeId; + Temperature[x, y] = _initialTemperature[typeId]; + BurnTime[x, y] = _burnDuration[typeId]; + Burning[x, y] = _burningInit[typeId]; + SparkTime[x, y] = 0; + Lifetime[x, y] = _defaultLifetime[typeId]; + _pressureDuration[x, y] = 0f; + _cellAge[x, y] = 0f; + _integrity[x, y] = _durability[typeId]; + ExpandOccupiedBoundsToInclude(x, y); + MarkVisualDirty(x, y); + } + + private void ReplaceCell(int x, int y, ushort typeId) + { + TypeId[x, y] = typeId; + Temperature[x, y] = _initialTemperature[typeId]; + BurnTime[x, y] = _burnDuration[typeId]; + Burning[x, y] = _burningInit[typeId]; + SparkTime[x, y] = 0; + Lifetime[x, y] = _defaultLifetime[typeId]; + _pressureDuration[x, y] = 0f; + _cellAge[x, y] = 0f; + _integrity[x, y] = _durability[typeId]; + ExpandOccupiedBoundsToInclude(x, y); + MarkVisualDirty(x, y); + MarkProcessed(x, y); + } + + private void ResetCell(int x, int y) + { + var typeId = TypeId[x, y]; + if (typeId != 0) + { + TrySpawnOnDeath(x, y, typeId); + } + + if (TypeId[x, y] != 0) + { + ParticleCount = Math.Max(0, ParticleCount - 1); + } + + MarkBoundsDirtyIfRemovingOccupiedCell(x, y); + ResetCellWithoutCountChange(x, y); + MarkVisualDirty(x, y); + } + + private void MoveCell(int x, int y, int nx, int ny) + { + TypeId[nx, ny] = TypeId[x, y]; + Temperature[nx, ny] = Temperature[x, y]; + BurnTime[nx, ny] = BurnTime[x, y]; + Burning[nx, ny] = Burning[x, y]; + SparkTime[nx, ny] = SparkTime[x, y]; + Lifetime[nx, ny] = Lifetime[x, y]; + _pressureDuration[nx, ny] = _pressureDuration[x, y]; + _cellAge[nx, ny] = _cellAge[x, y]; + _integrity[nx, ny] = _integrity[x, y]; + MarkBoundsDirtyIfRemovingOccupiedCell(x, y); + ResetCellWithoutCountChange(x, y); + ExpandOccupiedBoundsToInclude(nx, ny); + MarkVisualDirty(x, y); + MarkVisualDirty(nx, ny); + MarkProcessed(nx, ny); + InjectMovementWind(x, y, nx, ny, TypeId[nx, ny]); + InjectMovementPressure(x, y, nx, ny, TypeId[nx, ny]); + } + + private void SwapCells(int x, int y, int nx, int ny) + { + (TypeId[x, y], TypeId[nx, ny]) = (TypeId[nx, ny], TypeId[x, y]); + (Temperature[x, y], Temperature[nx, ny]) = (Temperature[nx, ny], Temperature[x, y]); + (BurnTime[x, y], BurnTime[nx, ny]) = (BurnTime[nx, ny], BurnTime[x, y]); + (Burning[x, y], Burning[nx, ny]) = (Burning[nx, ny], Burning[x, y]); + (SparkTime[x, y], SparkTime[nx, ny]) = (SparkTime[nx, ny], SparkTime[x, y]); + (Lifetime[x, y], Lifetime[nx, ny]) = (Lifetime[nx, ny], Lifetime[x, y]); + (_pressureDuration[x, y], _pressureDuration[nx, ny]) = (_pressureDuration[nx, ny], _pressureDuration[x, y]); + (_cellAge[x, y], _cellAge[nx, ny]) = (_cellAge[nx, ny], _cellAge[x, y]); + (_integrity[x, y], _integrity[nx, ny]) = (_integrity[nx, ny], _integrity[x, y]); + ExpandOccupiedBoundsToInclude(x, y); + ExpandOccupiedBoundsToInclude(nx, ny); + MarkVisualDirty(x, y); + MarkVisualDirty(nx, ny); + MarkProcessed(x, y); + MarkProcessed(nx, ny); + } + + private void ResetCellWithoutCountChange(int x, int y) + { + TypeId[x, y] = 0; + Temperature[x, y] = _settings.AmbientTemperature; + BurnTime[x, y] = 0f; + Burning[x, y] = 0; + SparkTime[x, y] = 0; + Lifetime[x, y] = 0f; + _pressureDuration[x, y] = 0f; + _cellAge[x, y] = 0f; + _integrity[x, y] = 0f; + MarkVisualDirty(x, y); + } + + private void DecayFields(float dt) + { + if (!TryGetSimulationBounds(out var startX, out var startY, out var endX, out var endY)) + { + return; + } + + var windDecay = MathF.Max(0f, 1f - (dt * 3.5f)); + var forceDecay = MathF.Max(0f, 1f - (dt * 1.2f)); + var pressureDecay = MathF.Max(0f, 1f - (dt * 4.5f)); + _hasActiveFieldBounds = false; + for (var y = startY; y <= endY; y++) + { + for (var x = startX; x <= endX; x++) + { + _windFieldX[x, y] *= windDecay; + _windFieldY[x, y] *= windDecay; + _forceFieldX[x, y] *= forceDecay; + _forceFieldY[x, y] *= forceDecay; + var localPressureDecay = pressureDecay * _pressureDecayMultiplier[Math.Min(TypeId[x, y], _pressureDecayMultiplier.Length - 1)]; + _airPressure[x, y] *= localPressureDecay; + if (MathF.Abs(_windFieldX[x, y]) < 0.02f) + { + _windFieldX[x, y] = 0f; + } + + if (MathF.Abs(_windFieldY[x, y]) < 0.02f) + { + _windFieldY[x, y] = 0f; + } + + if (MathF.Abs(_forceFieldX[x, y]) < 0.02f) + { + _forceFieldX[x, y] = 0f; + } + + if (MathF.Abs(_forceFieldY[x, y]) < 0.02f) + { + _forceFieldY[x, y] = 0f; + } + + if (MathF.Abs(_airPressure[x, y]) < 0.02f) + { + _airPressure[x, y] = 0f; + } + + if (TypeId[x, y] != 0) + { + _cellAge[x, y] += dt * 60f; + var maxIntegrity = _durability[TypeId[x, y]]; + if (_integrity[x, y] < maxIntegrity) + { + _integrity[x, y] = MathF.Min(maxIntegrity, _integrity[x, y] + (dt * 60f * MathF.Max(0.02f, _hardness[TypeId[x, y]] * 0.03f))); + } + } + + if (HasDynamicFieldAt(x, y)) + { + ExpandActiveFieldBoundsToInclude(x, y); + } + } + } + + _fieldBoundsDirty = false; + if (AreFieldVisualsEnabled()) + { + MarkVisualDirtyRect(startX, startY, endX, endY); + } + } + + private void InjectMovementWind(int x, int y, int nx, int ny, ushort typeId) + { + if (typeId == 0) + { + return; + } + + var kind = (ParticleKind)_kind[typeId]; + if (kind != ParticleKind.Solid && kind != ParticleKind.Liquid) + { + return; + } + + var moveX = nx - x; + var moveY = ny - y; + if (moveX == 0 && moveY == 0) + { + return; + } + + var strength = kind == ParticleKind.Solid ? 0.55f : 0.35f; + var impulseX = moveX * strength; + var impulseY = moveY * strength * 0.5f; + + AddWindImpulse(nx, ny, impulseX * 0.5f, impulseY); + AddWindImpulse(nx - Math.Sign(moveX == 0 ? 1 : moveX), ny, impulseX * 0.25f, impulseY * 0.35f); + AddWindImpulse(nx + Math.Sign(moveX == 0 ? 1 : moveX), ny, impulseX * 0.25f, impulseY * 0.35f); + AddWindImpulse(nx, ny - 1, impulseX * 0.15f, impulseY * 0.2f); + } + + private void AddWindImpulse(int x, int y, float impulseX, float impulseY) + { + if (!TryNormalizeCoordinate(ref x, ref y)) + { + return; + } + + if (_settings.OuterWall && IsBoundary(x, y)) + { + return; + } + + var hadDynamicBefore = HasDynamicFieldAt(x, y); + _windFieldX[x, y] = Math.Clamp(_windFieldX[x, y] + impulseX, -8f, 8f); + _windFieldY[x, y] = Math.Clamp(_windFieldY[x, y] + impulseY, -8f, 8f); + TrackDynamicFieldMutation(x, y, hadDynamicBefore); + } + + private void InjectMovementPressure(int x, int y, int nx, int ny, ushort typeId) + { + if (typeId == 0) + { + return; + } + + var kind = (ParticleKind)_kind[typeId]; + if (kind != ParticleKind.Solid && kind != ParticleKind.Liquid) + { + return; + } + + var moveY = ny - y; + if (moveY <= 0) + { + return; + } + + var sourceAge = _cellAge[nx, ny]; + var impulse = ComputeMovementPressureImpulse(typeId, (ParticleKind)_kind[typeId], moveY, sourceAge); + if (impulse <= 0f) + { + return; + } + + AddPressureImpulse(nx, ny, impulse); + AddPressureImpulse(nx - 1, ny, impulse * 0.3f); + AddPressureImpulse(nx + 1, ny, impulse * 0.3f); + } + + private void AddPressureImpulse(int x, int y, float amount) + { + if (!TryNormalizeCoordinate(ref x, ref y)) + { + return; + } + + if (_settings.OuterWall && IsBoundary(x, y)) + { + return; + } + + var hadDynamicBefore = HasDynamicFieldAt(x, y); + _airPressure[x, y] = Math.Clamp(_airPressure[x, y] + amount, -10f, 10f); + TrackDynamicFieldMutation(x, y, hadDynamicBefore); + } + + private void TriggerExplosion(int x, int y, ushort typeId, ref uint seed) + { + var marker = _activeStepToken != 0 ? _activeStepToken : Frame + 1; + if (!InBounds(x, y)) + { + return; + } + + if (_explosionFrame[x, y] == marker) + { + return; + } + + var pending = new Stack<(int X, int Y, ushort TypeId)>(); + _explosionFrame[x, y] = marker; + pending.Push((x, y, typeId)); + + while (pending.Count > 0) + { + var current = pending.Pop(); + var cx = current.X; + var cy = current.Y; + var currentTypeId = current.TypeId; + var radius = Math.Max(1, (int)_explosionRadius[currentTypeId]); + var force = MathF.Max(2f, _explosionForce[currentTypeId]); + + for (var dx = -radius; dx <= radius; dx++) + { + for (var dy = -radius; dy <= radius; dy++) + { + var distanceSq = (dx * dx) + (dy * dy); + if (distanceSq > radius * radius) + { + continue; + } + + var tx = cx + dx; + var ty = cy + dy; + if (!TryNormalizeCoordinate(ref tx, ref ty)) + { + continue; + } + + if (_settings.OuterWall && IsBoundary(tx, ty)) + { + continue; + } + + var distance = MathF.Max(1f, MathF.Sqrt(distanceSq)); + var falloff = 1f - (distance / radius); + var hadDynamicBefore = HasDynamicFieldAt(tx, ty); + _airPressure[tx, ty] = Math.Clamp(_airPressure[tx, ty] + (force * falloff * 2.5f), -10f, 10f); + Temperature[tx, ty] += force * falloff * 25f; + if (distanceSq > 0) + { + _forceFieldX[tx, ty] = Math.Clamp(_forceFieldX[tx, ty] + (dx / distance) * force * falloff, -8f, 8f); + _forceFieldY[tx, ty] = Math.Clamp(_forceFieldY[tx, ty] + (dy / distance) * force * falloff, -8f, 8f); + } + TrackDynamicFieldMutation(tx, ty, hadDynamicBefore); + + var neighborId = TypeId[tx, ty]; + if (neighborId == 0) + { + if (_idSmoke != 0 && NextChance(ref seed, 0.08f)) + { + SetCell(tx, ty, _idSmoke); + } + else if (_idFire != 0 && NextChance(ref seed, 0.05f)) + { + SetCell(tx, ty, _idFire); + } + + continue; + } + + if (neighborId == _idWall || neighborId == _idGlass) + { + continue; + } + + if (_explosive[neighborId] != 0 && NextChance(ref seed, 0.25f)) + { + if (_explosionFrame[tx, ty] != marker) + { + _explosionFrame[tx, ty] = marker; + pending.Push((tx, ty, neighborId)); + } + + ResetCell(tx, ty); + continue; + } + + if (_flamability[neighborId] > 0f && _idFire != 0 && NextChance(ref seed, 0.12f)) + { + ReplaceCell(tx, ty, _idFire); + continue; + } + + if (_mass[neighborId] < 0.5f && NextChance(ref seed, 0.18f + falloff * 0.22f)) + { + var brokenType = _brokenTarget[neighborId]; + if (brokenType != 0 && brokenType != neighborId) + { + ReplaceCell(tx, ty, brokenType); + } + else + { + ResetCell(tx, ty); + } + } + } + } + } + } + + private void ApplyRadialForceBrushAtPixel(int centerX, int centerY, int brushRadius, float strength, bool inward, ToolProfile profile) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + var effectiveRadius = Math.Max(brushRadius, profile.RadiusCells); + + for (var dx = -effectiveRadius; dx <= effectiveRadius; dx++) + { + for (var dy = -effectiveRadius; dy <= effectiveRadius; dy++) + { + var radiusSq = (dx * dx) + (dy * dy); + if (radiusSq == 0 || radiusSq > effectiveRadius * effectiveRadius) + { + continue; + } + + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (!TryNormalizeCoordinate(ref x, ref y)) + { + continue; + } + + if (_settings.OuterWall && IsBoundary(x, y)) + { + continue; + } + + var distance = MathF.Sqrt(radiusSq); + if (!ToolAffectsCell(profile.Affects, x, y)) + { + continue; + } + + var falloff = ComputeFalloff(distance, effectiveRadius, profile.Falloff); + var dirX = dx / distance; + var dirY = dy / distance; + if (inward) + { + dirX = -dirX; + dirY = -dirY; + } + + var turbulence = SampleTurbulence(x, y, profile.Turbulence); + var hadDynamicBefore = HasDynamicFieldAt(x, y); + _forceFieldX[x, y] = Math.Clamp(_forceFieldX[x, y] + (dirX + turbulence.X) * falloff * strength * profile.Strength, -8f, 8f); + _forceFieldY[x, y] = Math.Clamp(_forceFieldY[x, y] + (dirY + turbulence.Y) * falloff * strength * profile.Strength, -8f, 8f); + TrackDynamicFieldMutation(x, y, hadDynamicBefore); + } + } + } + + private float ComputeMovementPressureImpulse(ushort typeId, ParticleKind kind, int moveY, float cellAge) + { + if (moveY <= 0 || cellAge < 4f) + { + return 0f; + } + + var impact = MathF.Max(0f, _mass[typeId] - 1.1f) * 0.08f; + impact *= MathF.Max(0.45f, _velocity[typeId] + 0.4f); + + if (kind == ParticleKind.Liquid) + { + impact *= 0.3f; + } + else if (kind == ParticleKind.Solid) + { + impact *= _isStatic[typeId] != 0 ? 0f : 0.55f; + } + + if (_isMolten[typeId] != 0) + { + impact *= 0.45f; + } + + return Math.Clamp(impact, 0f, 0.4f); + } + + private float GetNetForceX(int x, int y, ushort typeId) + { + var pressureGradient = SamplePressure(x - 1, y) - SamplePressure(x + 1, y); + var pressureInfluence = GetPressureForceInfluence(typeId, x, y); + return _settings.WindX + _windFieldX[x, y] + _forceFieldX[x, y] + (pressureGradient * pressureInfluence); + } + + private float GetNetForceY(int x, int y, ushort typeId) + { + var pressureGradient = SamplePressure(x, y + 1) - SamplePressure(x, y - 1); + var pressureInfluence = GetPressureForceInfluence(typeId, x, y) * 0.85f; + return _settings.WindY + _windFieldY[x, y] + _forceFieldY[x, y] + (pressureGradient * pressureInfluence); + } + + private float GetPressureForceInfluence(ushort typeId, int x, int y) + { + if (_cellAge[x, y] < 4f) + { + return 0f; + } + + return ((ParticleBehaviorKind)_behaviorKind[typeId], (ParticleKind)_kind[typeId]) switch + { + (ParticleBehaviorKind.Fire, _) => 0.09f, + (ParticleBehaviorKind.Plasma, _) => 0.1f, + (_, ParticleKind.Gas) => 0.08f, + (_, ParticleKind.Liquid) => 0.025f, + (_, ParticleKind.Solid) => _isStatic[typeId] != 0 ? 0f : 0.015f, + _ => 0.03f, + }; + } + + private float SamplePressure(int x, int y) + { + if (!TryNormalizeCoordinate(ref x, ref y)) + { + return 0f; + } + + return _airPressure[x, y]; + } +} diff --git a/Sand.Core/SandSimulation.Infrastructure.cs b/Sand.Core/SandSimulation.Infrastructure.cs new file mode 100644 index 0000000..6901f86 --- /dev/null +++ b/Sand.Core/SandSimulation.Infrastructure.cs @@ -0,0 +1,799 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + private uint GetVisualSettingsStamp() + { + unchecked + { + var stamp = 17u; + stamp = (stamp * 31u) + (_settings.EnablePressureVisuals ? 1u : 0u); + stamp = (stamp * 31u) + (_settings.EnableWindVisuals ? 1u : 0u); + stamp = (stamp * 31u) + (_settings.EnableTempVisuals ? 1u : 0u); + stamp = (stamp * 31u) + (_settings.EnableGasEffect ? 1u : 0u); + stamp = (stamp * 31u) + (_settings.EnableGlow ? 1u : 0u); + stamp = (stamp * 31u) + (uint)BitConverter.SingleToInt32Bits(_settings.AmbientTemperature); + return stamp; + } + } + + private void EnsureVisualSettingsState() + { + var stamp = GetVisualSettingsStamp(); + if (_visualSettingsStamp == stamp) + { + return; + } + + _visualSettingsStamp = stamp; + MarkFullVisualDirty(); + } + + private void MarkFullVisualDirty() + { + _fullVisualDirty = true; + _hasDirtyVisualBounds = true; + _minDirtyVisualX = 0; + _minDirtyVisualY = 0; + _maxDirtyVisualX = Width - 1; + _maxDirtyVisualY = Height - 1; + } + + private bool AreFieldVisualsEnabled() => _settings.EnablePressureVisuals || _settings.EnableWindVisuals; + + private void MarkVisualDirty(int x, int y) + { + if (!InBounds(x, y)) + { + return; + } + + if (_deferVisualDirtyTracking) + { + return; + } + + if (_fullVisualDirty) + { + return; + } + + if (!_hasDirtyVisualBounds) + { + _minDirtyVisualX = _maxDirtyVisualX = x; + _minDirtyVisualY = _maxDirtyVisualY = y; + _hasDirtyVisualBounds = true; + return; + } + + _minDirtyVisualX = Math.Min(_minDirtyVisualX, x); + _minDirtyVisualY = Math.Min(_minDirtyVisualY, y); + _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, x); + _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, y); + } + + private void MarkVisualDirtyRect(int startX, int startY, int endX, int endY) + { + if (_deferVisualDirtyTracking) + { + return; + } + + if (_fullVisualDirty) + { + return; + } + + if (startX > endX || startY > endY) + { + return; + } + + startX = Math.Max(0, startX); + startY = Math.Max(0, startY); + endX = Math.Min(Width - 1, endX); + endY = Math.Min(Height - 1, endY); + if (startX > endX || startY > endY) + { + return; + } + + if (!_hasDirtyVisualBounds) + { + _minDirtyVisualX = startX; + _minDirtyVisualY = startY; + _maxDirtyVisualX = endX; + _maxDirtyVisualY = endY; + _hasDirtyVisualBounds = true; + return; + } + + _minDirtyVisualX = Math.Min(_minDirtyVisualX, startX); + _minDirtyVisualY = Math.Min(_minDirtyVisualY, startY); + _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, endX); + _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, endY); + } + + private bool TryGetDirtyVisualBounds(out int startX, out int startY, out int endX, out int endY) + { + if (_fullVisualDirty) + { + startX = 0; + startY = 0; + endX = Width - 1; + endY = Height - 1; + return true; + } + + if (_hasDirtyVisualBounds) + { + startX = _minDirtyVisualX; + startY = _minDirtyVisualY; + endX = _maxDirtyVisualX; + endY = _maxDirtyVisualY; + return true; + } + + startX = startY = endX = endY = 0; + return false; + } + + private void ClearDirtyVisualBounds() + { + _fullVisualDirty = false; + _hasDirtyVisualBounds = false; + } + + private void UpdateCachedVisualBuffers() + { + EnsureVisualSettingsState(); + if (!TryGetDirtyVisualBounds(out var startX, out var startY, out var endX, out var endY)) + { + return; + } + + for (var y = startY; y <= endY; y++) + { + for (var x = startX; x <= endX; x++) + { + var color = ResolveVisualColor(x, y); + var pixelIndex = (y * Width) + x; + var rgbIndex = pixelIndex * 3; + var rgbaIndex = pixelIndex * 4; + _rgbBuffer[rgbIndex] = color.R; + _rgbBuffer[rgbIndex + 1] = color.G; + _rgbBuffer[rgbIndex + 2] = color.B; + _rgbaBuffer[rgbaIndex] = color.R; + _rgbaBuffer[rgbaIndex + 1] = color.G; + _rgbaBuffer[rgbaIndex + 2] = color.B; + _rgbaBuffer[rgbaIndex + 3] = 255; + } + } + + ClearDirtyVisualBounds(); + } + + private bool HasDynamicFieldAt(int x, int y) + { + return + MathF.Abs(_windFieldX[x, y]) >= 0.02f || + MathF.Abs(_windFieldY[x, y]) >= 0.02f || + MathF.Abs(_forceFieldX[x, y]) >= 0.02f || + MathF.Abs(_forceFieldY[x, y]) >= 0.02f || + MathF.Abs(_airPressure[x, y]) >= 0.02f; + } + + private void ExpandActiveFieldBoundsToInclude(int x, int y) + { + if (!_hasActiveFieldBounds) + { + _minActiveFieldX = _maxActiveFieldX = x; + _minActiveFieldY = _maxActiveFieldY = y; + _hasActiveFieldBounds = true; + return; + } + + _minActiveFieldX = Math.Min(_minActiveFieldX, x); + _minActiveFieldY = Math.Min(_minActiveFieldY, y); + _maxActiveFieldX = Math.Max(_maxActiveFieldX, x); + _maxActiveFieldY = Math.Max(_maxActiveFieldY, y); + } + + private void TrackDynamicFieldMutation(int x, int y, bool hadDynamicBefore) + { + if (!InBounds(x, y)) + { + return; + } + + var hasDynamicAfter = HasDynamicFieldAt(x, y); + if (hasDynamicAfter) + { + ExpandActiveFieldBoundsToInclude(x, y); + } + else if (hadDynamicBefore) + { + _fieldBoundsDirty = true; + } + + if (AreFieldVisualsEnabled()) + { + MarkVisualDirty(x, y); + } + } + + private void ClearDynamicFieldsAt(int x, int y) + { + var hadDynamicBefore = HasDynamicFieldAt(x, y); + _windFieldX[x, y] = 0f; + _windFieldY[x, y] = 0f; + _forceFieldX[x, y] = 0f; + _forceFieldY[x, y] = 0f; + _airPressure[x, y] = 0f; + if (hadDynamicBefore) + { + _fieldBoundsDirty = true; + if (AreFieldVisualsEnabled()) + { + MarkVisualDirty(x, y); + } + } + } + + private bool TryGetOccupiedBoundsOnly(out int startX, out int startY, out int endX, out int endY) + { + if (_hasOccupiedBounds) + { + startX = _minOccupiedX; + startY = _minOccupiedY; + endX = _maxOccupiedX; + endY = _maxOccupiedY; + return true; + } + + startX = startY = endX = endY = 0; + return false; + } + + private bool TryGetSimulationBounds(out int startX, out int startY, out int endX, out int endY) + { + EnsureOccupiedBounds(); + if (_fieldBoundsDirty && (_hasActiveFieldBounds || _hasOccupiedBounds)) + { + var recomputeStartX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldX, _minOccupiedX) : _hasActiveFieldBounds ? _minActiveFieldX : _minOccupiedX; + var recomputeStartY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldY, _minOccupiedY) : _hasActiveFieldBounds ? _minActiveFieldY : _minOccupiedY; + var recomputeEndX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldX, _maxOccupiedX) : _hasActiveFieldBounds ? _maxActiveFieldX : _maxOccupiedX; + var recomputeEndY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldY, _maxOccupiedY) : _hasActiveFieldBounds ? _maxActiveFieldY : _maxOccupiedY; + RebuildActiveFieldBounds(recomputeStartX, recomputeStartY, recomputeEndX, recomputeEndY); + } + + var hasBounds = false; + startX = startY = endX = endY = 0; + if (_hasOccupiedBounds) + { + startX = _minOccupiedX; + startY = _minOccupiedY; + endX = _maxOccupiedX; + endY = _maxOccupiedY; + hasBounds = true; + } + + if (_hasActiveFieldBounds) + { + if (!hasBounds) + { + startX = _minActiveFieldX; + startY = _minActiveFieldY; + endX = _maxActiveFieldX; + endY = _maxActiveFieldY; + hasBounds = true; + } + else + { + startX = Math.Min(startX, _minActiveFieldX); + startY = Math.Min(startY, _minActiveFieldY); + endX = Math.Max(endX, _maxActiveFieldX); + endY = Math.Max(endY, _maxActiveFieldY); + } + } + + return hasBounds; + } + + private void MarkBoundsUnionDirty(bool hadFirstBounds, int firstStartX, int firstStartY, int firstEndX, int firstEndY) + { + int secondStartX; + int secondStartY; + int secondEndX; + int secondEndY; + var hasSecondBounds = AreFieldVisualsEnabled() + ? TryGetSimulationBounds(out secondStartX, out secondStartY, out secondEndX, out secondEndY) + : TryGetOccupiedBoundsOnly(out secondStartX, out secondStartY, out secondEndX, out secondEndY); + + if (hasSecondBounds) + { + if (!hadFirstBounds) + { + MarkVisualDirtyRect(secondStartX, secondStartY, secondEndX, secondEndY); + return; + } + + MarkVisualDirtyRect( + Math.Min(firstStartX, secondStartX), + Math.Min(firstStartY, secondStartY), + Math.Max(firstEndX, secondEndX), + Math.Max(firstEndY, secondEndY)); + return; + } + + if (hadFirstBounds) + { + MarkVisualDirtyRect(firstStartX, firstStartY, firstEndX, firstEndY); + } + } + + private ushort ResolveOptionalTypeId(string? particleId) + { + if (particleId is null) + { + return 0; + } + + return _library.TryGetTypeId(particleId, out var typeId) ? typeId : (ushort)0; + } + + private float ResolveDefaultLifetime(ParticleDef definition, ParticleBalanceProfile balance) + { + var lifetime = (definition.Lifetime ?? 0f) * MathF.Max(0.01f, balance.LifetimeMultiplier); + if (lifetime <= 0f && balance.MaxLifetimeTicks > 0f) + { + lifetime = (balance.MinLifetimeTicks + balance.MaxLifetimeTicks) * 0.5f; + } + + if (balance.MinLifetimeTicks > 0f) + { + lifetime = MathF.Max(lifetime, balance.MinLifetimeTicks); + } + + if (balance.MaxLifetimeTicks > 0f) + { + lifetime = MathF.Min(lifetime, balance.MaxLifetimeTicks); + } + + return lifetime; + } + + private ToolProfile BuildToolProfile(string id, float defaultStrength, int fallbackRadiusCells) + { + if (_library.TryGetTypeId(id, out var typeId)) + { + var definition = _library.GetDefinition(typeId); + var radiusCells = definition.Radius > 0f ? Math.Max(fallbackRadiusCells, (int)MathF.Ceiling(definition.Radius / (ParticleSize * 4f))) : fallbackRadiusCells; + var strength = definition.WindStrength > 0f ? definition.WindStrength : definition.GravityStrength > 0f ? definition.GravityStrength : definition.RepulsionStrength > 0f ? definition.RepulsionStrength : defaultStrength; + return new ToolProfile( + definition.Id, + radiusCells, + strength, + definition.ForceFalloff <= 0f ? 1f : definition.ForceFalloff, + definition.Turbulence, + definition.Affects.Length == 0 ? ["all"] : definition.Affects); + } + + return new ToolProfile(id, fallbackRadiusCells, defaultStrength, 1f, 0f, ["all"]); + } + + private void ApplyDirectionalBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY, ToolProfile profile, float[,] fieldX, float[,] fieldY, bool addPressure = false) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + var effectiveRadius = Math.Max(brushRadius, profile.RadiusCells); + var magnitude = MathF.Sqrt((forceX * forceX) + (forceY * forceY)); + if (magnitude <= 0.001f) + { + forceX = 1f; + forceY = 0f; + magnitude = 1f; + } + + for (var dx = -effectiveRadius; dx <= effectiveRadius; dx++) + { + for (var dy = -effectiveRadius; dy <= effectiveRadius; dy++) + { + if ((dx * dx) + (dy * dy) > effectiveRadius * effectiveRadius) + { + continue; + } + + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (!TryNormalizeCoordinate(ref x, ref y)) + { + continue; + } + + if (_settings.OuterWall && IsBoundary(x, y)) + { + continue; + } + + if (!ToolAffectsCell(profile.Affects, x, y)) + { + continue; + } + + var distance = MathF.Sqrt((dx * dx) + (dy * dy)); + var falloff = ComputeFalloff(distance, effectiveRadius, profile.Falloff); + var turbulence = SampleTurbulence(x, y, profile.Turbulence); + var hadDynamicBefore = HasDynamicFieldAt(x, y); + fieldX[x, y] = Math.Clamp(fieldX[x, y] + (((forceX / magnitude) + turbulence.X) * falloff * profile.Strength), -8f, 8f); + fieldY[x, y] = Math.Clamp(fieldY[x, y] + (((forceY / magnitude) + turbulence.Y) * falloff * profile.Strength), -8f, 8f); + if (addPressure) + { + _airPressure[x, y] = Math.Clamp(_airPressure[x, y] + falloff * profile.Strength, -10f, 10f); + } + TrackDynamicFieldMutation(x, y, hadDynamicBefore); + } + } + } + + private bool ToolAffectsCell(string[] affects, int x, int y) + { + if (affects.Length == 0 || affects.Contains("all", StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + var typeId = TypeId[x, y]; + if (typeId == 0) + { + return true; + } + + var definition = _library.GetDefinition(typeId); + foreach (var affect in affects) + { + if (string.Equals(affect, definition.Id, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (affect == "solid" && definition.Kind == ParticleKind.Solid) + { + return true; + } + + if (affect == "liquid" && definition.Kind == ParticleKind.Liquid) + { + return true; + } + + if (affect == "gas" && definition.Kind == ParticleKind.Gas) + { + return true; + } + } + + return false; + } + + private static float ComputeFalloff(float distance, int radius, float exponent) + { + if (radius <= 0) + { + return 0f; + } + + var normalized = 1f - (distance / radius); + if (normalized <= 0f) + { + return 0f; + } + + return MathF.Pow(normalized, MathF.Max(0.1f, exponent)); + } + + private (float X, float Y) SampleTurbulence(int x, int y, float turbulence) + { + if (turbulence <= 0.001f) + { + return (0f, 0f); + } + + var hash = Hash(x, y, Frame); + var tx = (((hash & 0xFFu) / 255f) - 0.5f) * turbulence; + var ty = ((((hash >> 8) & 0xFFu) / 255f) - 0.5f) * turbulence; + return (tx, ty); + } + + private void TrySpawnOnDeath(int x, int y, ushort typeId) + { + var spawnType = _producesOnDeathTarget[typeId]; + if (spawnType == 0) + { + return; + } + + SpawnNeighborParticle(x, y, spawnType, Hash(x, y, Frame + 17)); + } + + private bool SpawnNeighborParticle(int x, int y, ushort spawnType, uint seed) + { + var offsets = new (int X, int Y)[] + { + (0, -1), + (-1, 0), + (1, 0), + (0, 1), + (-1, -1), + (1, -1), + (-1, 1), + (1, 1), + }; + + var start = (int)(seed % (uint)offsets.Length); + for (var i = 0; i < offsets.Length; i++) + { + var offset = offsets[(start + i) % offsets.Length]; + var nx = x + offset.X; + var ny = y + offset.Y; + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + continue; + } + + if ((_settings.OuterWall && IsBoundary(nx, ny)) || TypeId[nx, ny] != 0) + { + continue; + } + + SetCell(nx, ny, spawnType); + return true; + } + + return false; + } + + private bool BreakCellFromPressure(int x, int y, ushort typeId, ref uint seed) + { + var brokenType = _brokenTarget[typeId]; + if (brokenType != 0 && brokenType != typeId) + { + ReplaceCell(x, y, brokenType); + Temperature[x, y] = MathF.Max(Temperature[x, y], _initialTemperature[brokenType]); + return true; + } + + var isHotBreak = Temperature[x, y] >= Math.Max(200f, _burnTemperature[typeId]); + if (_idSmoke != 0 && isHotBreak && NextChance(ref seed, 0.08f)) + { + SpawnNeighborParticle(x, y, _idSmoke, seed); + } + + ResetCell(x, y); + return true; + } + + private void EnsureOccupiedBounds() + { + if (_boundsDirty && _staleOccupiedBoundsFrames >= 8) + { + RecomputeOccupiedBounds(); + } + } + + private void MarkBoundsDirtyIfRemovingOccupiedCell(int x, int y) + { + if (ParticleCount == 0) + { + _hasOccupiedBounds = false; + _boundsDirty = false; + return; + } + + if (!_hasOccupiedBounds) + { + _boundsDirty = true; + return; + } + + if (x == _minOccupiedX || x == _maxOccupiedX || y == _minOccupiedY || y == _maxOccupiedY) + { + _boundsDirty = true; + } + } + + private void ExpandOccupiedBoundsToInclude(int x, int y) + { + if (!_hasOccupiedBounds) + { + _minOccupiedX = _maxOccupiedX = x; + _minOccupiedY = _maxOccupiedY = y; + _hasOccupiedBounds = true; + return; + } + + _minOccupiedX = Math.Min(_minOccupiedX, x); + _minOccupiedY = Math.Min(_minOccupiedY, y); + _maxOccupiedX = Math.Max(_maxOccupiedX, x); + _maxOccupiedY = Math.Max(_maxOccupiedY, y); + } + + private void RecomputeOccupiedBounds() + { + _hasOccupiedBounds = false; + for (var y = 0; y < Height; y++) + { + for (var x = 0; x < Width; x++) + { + if (TypeId[x, y] != 0) + { + ExpandOccupiedBoundsToInclude(x, y); + } + } + } + + _boundsDirty = false; + _staleOccupiedBoundsFrames = 0; + } + + private void ClearBoundaryWalls() + { + for (var x = 0; x < Width; x++) + { + TryClearBoundaryWall(x, 0); + TryClearBoundaryWall(x, Height - 1); + } + + for (var y = 0; y < Height; y++) + { + TryClearBoundaryWall(0, y); + TryClearBoundaryWall(Width - 1, y); + } + } + + private void TryClearBoundaryWall(int x, int y) + { + if (TypeId[x, y] == _idWall) + { + ParticleCount = Math.Max(0, ParticleCount - 1); + MarkBoundsDirtyIfRemovingOccupiedCell(x, y); + ResetCellWithoutCountChange(x, y); + MarkVisualDirty(x, y); + } + } + + private void ApplyBoundaryWallsFast() + { + for (var x = 0; x < Width; x++) + { + SetBoundaryWall(x, 0); + SetBoundaryWall(x, Height - 1); + } + + for (var y = 0; y < Height; y++) + { + SetBoundaryWall(0, y); + SetBoundaryWall(Width - 1, y); + } + } + + private void SetBoundaryWall(int x, int y) + { + if (TypeId[x, y] == _idWall) + { + return; + } + + if (TypeId[x, y] == 0) + { + ParticleCount++; + } + + TypeId[x, y] = _idWall; + Temperature[x, y] = _initialTemperature[_idWall]; + BurnTime[x, y] = _burnDuration[_idWall]; + Burning[x, y] = _burningInit[_idWall]; + SparkTime[x, y] = 0; + Lifetime[x, y] = _defaultLifetime[_idWall]; + _pressureDuration[x, y] = 0f; + _cellAge[x, y] = 0f; + _integrity[x, y] = _durability[_idWall]; + ExpandOccupiedBoundsToInclude(x, y); + MarkVisualDirty(x, y); + MarkProcessed(x, y); + } + + private bool InBounds(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height; + + private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == Width - 1 || y == Height - 1; + + private bool TryNormalizeCoordinate(ref int x, ref int y) + { + if (_settings.WrapParticles) + { + x = ((x % Width) + Width) % Width; + y = ((y % Height) + Height) % Height; + return true; + } + + return InBounds(x, y); + } + + private void MarkProcessed(int x, int y) + { + if (_activeStepToken != 0 && InBounds(x, y)) + { + _processedFrame[x, y] = _activeStepToken; + } + } + + private void ForEachNeighbor8(int x, int y, Action visitor) + { + for (var dx = -1; dx <= 1; dx++) + { + for (var dy = -1; dy <= 1; dy++) + { + if (dx == 0 && dy == 0) + { + continue; + } + + var nx = x + dx; + var ny = y + dy; + if (TryNormalizeCoordinate(ref nx, ref ny)) + { + visitor(nx, ny); + } + } + } + } + + private static uint Hash(int x, int y, int frame) + { + unchecked + { + var seed = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(frame * 83492791); + seed ^= seed >> 13; + return seed * 1274126177u; + } + } + + private static bool Chance(uint hash, float threshold) => (hash & 0xFFFF) / 65535f < threshold; + + private static bool NextChance(ref uint seed, float threshold) + { + unchecked + { + seed = (seed * 1664525u) + 1013904223u; + } + + return Chance(seed, threshold); + } + + private static float Clamp(float value, float min, float max) + { + if (min > max) + { + (min, max) = (max, min); + } + + return MathF.Min(max, MathF.Max(min, value)); + } + + private void RebuildActiveFieldBounds(int startX, int startY, int endX, int endY) + { + _hasActiveFieldBounds = false; + for (var y = startY; y <= endY; y++) + { + for (var x = startX; x <= endX; x++) + { + if (HasDynamicFieldAt(x, y)) + { + ExpandActiveFieldBoundsToInclude(x, y); + } + } + } + + _fieldBoundsDirty = false; + } +} diff --git a/Sand.Core/SandSimulation.Movement.cs b/Sand.Core/SandSimulation.Movement.cs new file mode 100644 index 0000000..0952469 --- /dev/null +++ b/Sand.Core/SandSimulation.Movement.cs @@ -0,0 +1,493 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + private void ApplyMovement(int x, int y, ushort typeId, bool leftFirst, ref uint seed) + { + if (_isStatic[typeId] != 0) + { + return; + } + + var netForceX = 0f; + var netForceY = 0f; + if (_hasActiveFieldBounds || _settings.WindX != 0f || _settings.WindY != 0f) + { + var forceResponse = GetForceResponse(typeId); + netForceX = GetNetForceX(x, y, typeId) * forceResponse; + netForceY = GetNetForceY(x, y, typeId) * forceResponse; + } + + if (netForceX > 0.25f) + { + leftFirst = false; + } + else if (netForceX < -0.25f) + { + leftFirst = true; + } + + switch ((ParticleBehaviorKind)_behaviorKind[typeId]) + { + case ParticleBehaviorKind.Fire: + MoveFire(x, y, leftFirst, ref seed, netForceX); + return; + case ParticleBehaviorKind.BurningWood: + return; + case ParticleBehaviorKind.Ember: + MoveEmber(x, y, leftFirst, ref seed, netForceX); + return; + case ParticleBehaviorKind.Plasma: + MovePlasma(x, y, leftFirst, ref seed, netForceX, netForceY); + return; + } + + switch ((ParticleKind)_kind[typeId]) + { + case ParticleKind.Solid: + MoveSolid(x, y, typeId, leftFirst, ref seed, netForceX); + break; + case ParticleKind.Liquid: + MoveLiquid(x, y, typeId, leftFirst, ref seed, netForceX); + break; + case ParticleKind.Gas: + MoveGas(x, y, typeId, leftFirst, ref seed, netForceX, netForceY); + break; + } + } + + private void MoveSolid(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX) + { + if (TryMoveSolidDown(x, y, typeId)) + { + return; + } + + if (!CanSlipSolid(typeId, ref seed, netForceX)) + { + return; + } + + if (leftFirst) + { + if (TryMoveEmpty(x, y, x - 1, y + 1)) + { + return; + } + + TryMoveEmpty(x, y, x + 1, y + 1); + return; + } + + if (TryMoveEmpty(x, y, x + 1, y + 1)) + { + return; + } + + TryMoveEmpty(x, y, x - 1, y + 1); + } + + private void MoveLiquid(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX) + { + if (TryMoveLiquidDown(x, y, typeId)) + { + return; + } + + var windPreferred = netForceX > 0.2f ? 1 : netForceX < -0.2f ? -1 : 0; + if (windPreferred != 0 && CanFlowLiquid(typeId, ref seed, lateral: true, netForceX)) + { + if (TryMoveEmpty(x, y, x + windPreferred, y)) + { + return; + } + } + + var diagonalAllowed = CanFlowLiquid(typeId, ref seed, lateral: false, netForceX); + var lateralAllowed = diagonalAllowed || CanFlowLiquid(typeId, ref seed, lateral: true, netForceX); + + if (leftFirst) + { + if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y + 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y + 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + + private void MoveGas(int x, int y, ushort typeId, bool leftFirst, ref uint seed, float netForceX, float netForceY) + { + if (CanRiseGas(typeId, ref seed, netForceY) && TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + var windPreferred = netForceX > 0.15f ? 1 : netForceX < -0.15f ? -1 : 0; + if (windPreferred != 0 && CanDriftGas(typeId, ref seed, netForceX, lateral: true) && TryMoveEmpty(x, y, x + windPreferred, y)) + { + return; + } + + var diagonalAllowed = CanDriftGas(typeId, ref seed, netForceX, lateral: false); + var lateralAllowed = diagonalAllowed || CanDriftGas(typeId, ref seed, netForceX, lateral: true); + + if (leftFirst) + { + if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y - 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y - 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x + 1, y - 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + + if (diagonalAllowed && TryMoveEmpty(x, y, x - 1, y - 1)) + { + return; + } + + if (lateralAllowed && TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + + private bool TryMoveInto(int x, int y, int nx, int ny, ushort typeId, bool swapAllowed) + { + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + return false; + } + + if (_settings.OuterWall && typeId != _idWall && IsBoundary(nx, ny)) + { + return false; + } + + var neighborId = TypeId[nx, ny]; + if (neighborId == 0) + { + MoveCell(x, y, nx, ny); + return true; + } + + if (!swapAllowed) + { + return false; + } + + var neighborKind = (ParticleKind)_kind[neighborId]; + var movableNeighbor = neighborKind == ParticleKind.Liquid || neighborKind == ParticleKind.Gas; + if (movableNeighbor && _mass[typeId] > _mass[neighborId]) + { + SwapCells(x, y, nx, ny); + return true; + } + + return false; + } + + private bool TryMoveEmpty(int x, int y, int nx, int ny) + { + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + return false; + } + + if (_settings.OuterWall && IsBoundary(nx, ny)) + { + return false; + } + + if (TypeId[nx, ny] != 0) + { + return false; + } + + MoveCell(x, y, nx, ny); + return true; + } + + private bool TryMoveSolidDown(int x, int y, ushort typeId) + { + var nx = x; + var ny = y + 1; + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + return false; + } + + if (_settings.OuterWall && IsBoundary(nx, ny)) + { + return false; + } + + var below = TypeId[nx, ny]; + if (below == 0) + { + MoveCell(x, y, nx, ny); + return true; + } + + var belowKind = (ParticleKind)_kind[below]; + if ((belowKind == ParticleKind.Liquid || belowKind == ParticleKind.Gas) && _mass[typeId] > _mass[below]) + { + SwapCells(x, y, nx, ny); + return true; + } + + return false; + } + + private bool TryMoveLiquidDown(int x, int y, ushort typeId) + { + var nx = x; + var ny = y + 1; + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + return false; + } + + if (_settings.OuterWall && IsBoundary(nx, ny)) + { + return false; + } + + var below = TypeId[nx, ny]; + if (below == 0) + { + MoveCell(x, y, nx, ny); + return true; + } + + var belowKind = (ParticleKind)_kind[below]; + if (belowKind == ParticleKind.Gas && _mass[typeId] > _mass[below]) + { + SwapCells(x, y, nx, ny); + return true; + } + + if (_isMolten[typeId] != 0 && belowKind == ParticleKind.Liquid && _mass[typeId] > _mass[below]) + { + SwapCells(x, y, nx, ny); + return true; + } + + return false; + } + + private void MoveFire(int x, int y, bool leftFirst, ref uint seed, float netForceX) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + var preferredRight = netForceX > 0.05f ? true : netForceX < -0.05f ? false : !leftFirst; + if (preferredRight) + { + if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + else + { + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + + if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + } + + if (NextChance(ref seed, 0.15f)) + { + ResetCell(x, y); + } + } + + private void MoveEmber(int x, int y, bool leftFirst, ref uint seed, float netForceX) + { + if (NextChance(ref seed, MathF.Max(0.1f, _upwardBias[TypeId[x, y]]))) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + if (leftFirst) + { + if (TryMoveEmpty(x, y, x - 1, y - 1) || TryMoveEmpty(x, y, x - 1, y)) + { + return; + } + } + else if (TryMoveEmpty(x, y, x + 1, y - 1) || TryMoveEmpty(x, y, x + 1, y)) + { + return; + } + } + + MoveSolid(x, y, TypeId[x, y], leftFirst, ref seed, netForceX); + } + + private void MovePlasma(int x, int y, bool leftFirst, ref uint seed, float netForceX, float netForceY) + { + if (TryMoveEmpty(x, y, x, y - 1)) + { + return; + } + + var lateralBias = MathF.Max(0.15f, _sideDriftBias[TypeId[x, y]]); + if (NextChance(ref seed, lateralBias)) + { + var dir = netForceX > 0.05f ? 1 : netForceX < -0.05f ? -1 : leftFirst ? -1 : 1; + if (TryMoveEmpty(x, y, x + dir, y - 1) || TryMoveEmpty(x, y, x + dir, y)) + { + return; + } + } + + MoveGas(x, y, TypeId[x, y], leftFirst, ref seed, netForceX, netForceY); + } + + private float GetEffectiveVelocity(ushort typeId) + { + if (_velocity[typeId] > 0f) + { + return _velocity[typeId]; + } + + return (ParticleKind)_kind[typeId] switch + { + ParticleKind.Solid => 0.45f, + ParticleKind.Liquid => 0.35f, + ParticleKind.Gas => 0.25f, + _ => 0.35f, + }; + } + + private float GetForceResponse(ushort typeId) + { + var mobility = 0.35f + GetEffectiveVelocity(typeId); + var damping = MathF.Max(0.25f, (_mass[typeId] * 0.85f) + (_friction[typeId] * 0.35f) + (_viscosity[typeId] * 0.45f)); + return Clamp((mobility / damping) * _forceResponseMultiplier[typeId], 0.15f, 2f); + } + + private bool CanSlipSolid(ushort typeId, ref uint seed, float netForceX) + { + if (MathF.Abs(netForceX) > 0.45f) + { + return true; + } + + var chance = Clamp(0.2f + (GetEffectiveVelocity(typeId) * 0.95f) - (_friction[typeId] * 0.35f), 0.1f, 0.95f); + return NextChance(ref seed, chance); + } + + private bool CanFlowLiquid(ushort typeId, ref uint seed, bool lateral, float netForceX) + { + var forceBonus = MathF.Min(0.25f, MathF.Abs(netForceX) * 0.08f); + var velocity = GetEffectiveVelocity(typeId); + var baseChance = lateral + ? 0.18f + (velocity * 0.95f) - (_viscosity[typeId] * 0.42f) - (_friction[typeId] * 0.08f) + : 0.28f + (velocity * 0.9f) - (_viscosity[typeId] * 0.28f) - (_friction[typeId] * 0.05f); + var flowScale = lateral ? _lateralFlowMultiplier[typeId] : _diagonalFlowMultiplier[typeId]; + var chance = Clamp((baseChance * flowScale) + forceBonus, 0.03f, 0.98f); + return NextChance(ref seed, chance); + } + + private bool CanRiseGas(ushort typeId, ref uint seed, float netForceY) + { + if (netForceY < -0.65f) + { + return true; + } + + var velocity = GetEffectiveVelocity(typeId); + var buoyancy = MathF.Max(0f, -netForceY) * 0.08f; + var chance = Clamp(0.1f + (velocity * 1.8f) - (_viscosity[typeId] * 0.22f) + buoyancy, 0.05f, 0.99f); + return NextChance(ref seed, chance); + } + + private bool CanDriftGas(ushort typeId, ref uint seed, float netForceX, bool lateral) + { + var forceBonus = MathF.Min(0.28f, MathF.Abs(netForceX) * 0.08f); + var velocity = GetEffectiveVelocity(typeId); + var baseChance = lateral + ? 0.18f + (velocity * 1.15f) - (_viscosity[typeId] * 0.1f) + : 0.22f + (velocity * 1.2f) - (_viscosity[typeId] * 0.16f); + var chance = Clamp(baseChance + forceBonus - (_friction[typeId] * 0.04f), 0.05f, 0.99f); + return NextChance(ref seed, chance); + } +} diff --git a/Sand.Core/SandSimulation.Painting.cs b/Sand.Core/SandSimulation.Painting.cs new file mode 100644 index 0000000..381cde9 --- /dev/null +++ b/Sand.Core/SandSimulation.Painting.cs @@ -0,0 +1,266 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + public void Clear() + { + Array.Clear(TypeId); + Array.Clear(Temperature); + Array.Clear(BurnTime); + Array.Clear(Burning); + Array.Clear(SparkTime); + Array.Clear(Lifetime); + Array.Clear(_pressureDuration); + Array.Clear(_cellAge); + Array.Clear(_integrity); + Array.Clear(_windFieldX); + Array.Clear(_windFieldY); + Array.Clear(_forceFieldX); + Array.Clear(_forceFieldY); + Array.Clear(_airPressure); + Array.Clear(_processedFrame); + Array.Clear(_explosionFrame); + Array.Clear(_rgbBuffer); + Array.Clear(_rgbaBuffer); + ParticleCount = 0; + Frame = 0; + _activeStepToken = 0; + _boundsDirty = false; + _staleOccupiedBoundsFrames = 0; + _hasOccupiedBounds = false; + _fieldBoundsDirty = false; + _hasActiveFieldBounds = false; + _hasDirtyVisualBounds = false; + _fullVisualDirty = true; + RefreshSettingsState(); + } + + public void RefreshSettingsState() + { + if (_settings.OuterWall && _idWall != 0) + { + for (var x = 0; x < Width; x++) + { + ForceSetCell(x, 0, _idWall); + ForceSetCell(x, Height - 1, _idWall); + } + + for (var y = 0; y < Height; y++) + { + ForceSetCell(0, y, _idWall); + ForceSetCell(Width - 1, y, _idWall); + } + } + else if (_idWall != 0) + { + ClearBoundaryWalls(); + } + + RecomputeOccupiedBounds(); + MarkFullVisualDirty(); + } + + public ushort GetTypeIdAtCell(int x, int y) => TypeId[x, y]; + + public float GetTemperatureAtCell(int x, int y) => Temperature[x, y]; + + public (float X, float Y) GetWindAtCell(int x, int y) => (_windFieldX[x, y], _windFieldY[x, y]); + + public (float X, float Y) GetForceAtCell(int x, int y) => (_forceFieldX[x, y], _forceFieldY[x, y]); + + public float GetAirPressureAtCell(int x, int y) => _airPressure[x, y]; + + public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) + { + ApplyDirectionalBrush(centerX, centerY, brushRadius, forceX, forceY, _windTool, _windFieldX, _windFieldY); + } + + public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) + { + ApplyDirectionalBrush(centerX, centerY, brushRadius, forceX, forceY, _airTool, _windFieldX, _windFieldY, addPressure: true); + } + + public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) + { + ApplyRadialForceBrushAtPixel(centerX, centerY, brushRadius, MathF.Abs(strength), inward: true, _gravityTool); + } + + public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) + { + ApplyRadialForceBrushAtPixel(centerX, centerY, brushRadius, MathF.Abs(strength), inward: false, _repulsorTool); + } + + public void CreateParticleAtPixel(int x, int y, string particleId) + { + var gridX = x / ParticleSize; + var gridY = y / ParticleSize; + if (!TryNormalizeCoordinate(ref gridX, ref gridY)) + { + return; + } + + if (!_library.TryGetTypeId(particleId.ToLowerInvariant(), out var typeId)) + { + return; + } + + if (_settings.OuterWall && typeId != _idWall && IsBoundary(gridX, gridY)) + { + return; + } + + if (TypeId[gridX, gridY] != 0) + { + if (typeId == _idSpark) + { + var targetId = TypeId[gridX, gridY]; + if (_conductivity[targetId] > 0.5f && SparkTime[gridX, gridY] == 0) + { + SparkTime[gridX, gridY] = 4; + } + } + return; + } + + SetCell(gridX, gridY, typeId, markProcessed: false, clearDynamics: true); + } + + public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId) + { + for (var dx = -brushRadius; dx <= brushRadius; dx++) + { + for (var dy = -brushRadius; dy <= brushRadius; dy++) + { + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + CreateParticleAtPixel(centerX + (dx * ParticleSize), centerY + (dy * ParticleSize), particleId); + } + } + } + + public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + if (!_library.TryGetTypeId(particleId.ToLowerInvariant(), out var typeId)) + { + return; + } + + var radiusSq = brushRadius * brushRadius; + var diameter = (brushRadius * 2) + 1; + var maxCells = diameter * diameter; + var attempts = Math.Max(maxParticles * 6, maxCells); + var placed = 0; + + for (var i = 0; i < attempts && placed < maxParticles; i++) + { + var hashX = Hash(gridCenterX + (i * 17), gridCenterY - (i * 31), seed + i); + var hashY = Hash(gridCenterX - (i * 29), gridCenterY + (i * 13), seed - i); + var dx = (int)(hashX % (uint)diameter) - brushRadius; + var dy = (int)(hashY % (uint)diameter) - brushRadius; + if ((dx * dx) + (dy * dy) > radiusSq) + { + continue; + } + + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (!TryNormalizeCoordinate(ref x, ref y)) + { + continue; + } + + if (_settings.OuterWall && typeId != _idWall && IsBoundary(x, y)) + { + continue; + } + + if (TypeId[x, y] != 0) + { + continue; + } + + SetCell(x, y, typeId, markProcessed: false, clearDynamics: true); + placed++; + } + } + + public void ClearParticleCircle(int centerX, int centerY, int brushRadius) + { + for (var dx = -brushRadius; dx <= brushRadius; dx++) + { + for (var dy = -brushRadius; dy <= brushRadius; dy++) + { + if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) + { + continue; + } + + var gridX = (centerX / ParticleSize) + dx; + var gridY = (centerY / ParticleSize) + dy; + if (!TryNormalizeCoordinate(ref gridX, ref gridY)) + { + continue; + } + + if (_settings.OuterWall && IsBoundary(gridX, gridY)) + { + continue; + } + + if (TypeId[gridX, gridY] != 0) + { + ResetCell(gridX, gridY); + } + } + } + } + + public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed) + { + var gridCenterX = centerX / ParticleSize; + var gridCenterY = centerY / ParticleSize; + var radiusSq = brushRadius * brushRadius; + var diameter = (brushRadius * 2) + 1; + var maxCells = diameter * diameter; + var attempts = Math.Max(maxParticles * 6, maxCells); + var cleared = 0; + + for (var i = 0; i < attempts && cleared < maxParticles; i++) + { + var hashX = Hash(gridCenterX + (i * 17), gridCenterY - (i * 31), seed + i); + var hashY = Hash(gridCenterX - (i * 29), gridCenterY + (i * 13), seed - i); + var dx = (int)(hashX % (uint)diameter) - brushRadius; + var dy = (int)(hashY % (uint)diameter) - brushRadius; + if ((dx * dx) + (dy * dy) > radiusSq) + { + continue; + } + + var x = gridCenterX + dx; + var y = gridCenterY + dy; + if (!TryNormalizeCoordinate(ref x, ref y)) + { + continue; + } + + if (_settings.OuterWall && IsBoundary(x, y)) + { + continue; + } + + if (TypeId[x, y] == 0) + { + continue; + } + + ResetCell(x, y); + cleared++; + } + } + +} diff --git a/Sand.Core/SandSimulation.Step.cs b/Sand.Core/SandSimulation.Step.cs new file mode 100644 index 0000000..a56e4b0 --- /dev/null +++ b/Sand.Core/SandSimulation.Step.cs @@ -0,0 +1,299 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + public void Step(float dt) + { + if (_settings.PauseSim) + { + return; + } + + if (_settings.EnableAcceleration && Accelerator is not null) + { + Accelerator.Step(this, dt); + return; + } + + int preStepStartX; + int preStepStartY; + int preStepEndX; + int preStepEndY; + var hadPreStepBounds = AreFieldVisualsEnabled() + ? TryGetSimulationBounds(out preStepStartX, out preStepStartY, out preStepEndX, out preStepEndY) + : TryGetOccupiedBoundsOnly(out preStepStartX, out preStepStartY, out preStepEndX, out preStepEndY); + _deferVisualDirtyTracking = true; + DecayFields(dt); + + if (ParticleCount == 0) + { + _deferVisualDirtyTracking = false; + MarkBoundsUnionDirty(hadPreStepBounds, preStepStartX, preStepStartY, preStepEndX, preStepEndY); + Frame++; + FrameStats.Frame = Frame; + FrameStats.ProcessedCells = 0; + FrameStats.ParticleCount = 0; + FrameStats.MinActiveX = 0; + FrameStats.MinActiveY = 0; + FrameStats.MaxActiveX = 0; + FrameStats.MaxActiveY = 0; + FrameStats.LoadedChunkCount = 0; + FrameStats.ActiveChunkCount = 0; + FrameStats.DirtyChunkCount = 0; + FrameStats.SteppedChunkCount = 0; + FrameStats.SleepingChunkCount = 0; + FrameStats.FieldPageCount = 0; + FrameStats.MoveAttemptCount = 0; + FrameStats.VerticalMoveAttemptCount = 0; + FrameStats.DiagonalMoveAttemptCount = 0; + FrameStats.LateralMoveAttemptCount = 0; + FrameStats.SuccessfulMoveCount = 0; + FrameStats.SwapAttemptCount = 0; + FrameStats.StalledMovableCount = 0; + FrameStats.MovementOnlyFastPathCount = 0; + FrameStats.FullRuntimeStepCount = 0; + FrameStats.FullRuntimeSolidCount = 0; + FrameStats.FullRuntimeLiquidCount = 0; + FrameStats.FullRuntimeGasCount = 0; + FrameStats.MovedParticleCount = 0; + FrameStats.SwappedParticleCount = 0; + FrameStats.VisualDirtyPageCount = 0; + FrameStats.FrameBuildBytesTouched = 0; + FrameStats.ActivationTimeMicroseconds = 0; + FrameStats.MovementTimeMicroseconds = 0; + FrameStats.RuntimeTimeMicroseconds = 0; + FrameStats.FieldDecayTimeMicroseconds = 0; + FrameStats.RenderTimeMicroseconds = 0; + return; + } + + EnsureOccupiedBounds(); + var margin = _settings.WrapParticles ? 0 : 1; + var startX = _settings.WrapParticles ? 0 : Math.Max(0, _minOccupiedX - margin); + var endX = _settings.WrapParticles ? Width - 1 : Math.Min(Width - 1, _maxOccupiedX + margin); + var startY = _settings.WrapParticles ? 0 : Math.Max(0, _minOccupiedY - margin); + var endY = _settings.WrapParticles ? Height - 1 : Math.Min(Height - 1, _maxOccupiedY + margin); + + _activeStepToken = Frame + 1; + var processedCells = 0; + + for (var y = endY; y >= startY; y--) + { + for (var x = startX; x <= endX; x++) + { + if (_processedFrame[x, y] == _activeStepToken) + { + continue; + } + + var typeId = TypeId[x, y]; + if (typeId == 0) + { + continue; + } + + var seed = Hash(x, y, Frame); + var leftFirst = (seed & 1u) == 0; + MarkProcessed(x, y); + processedCells++; + + if (TickLifetime(x, y, dt)) + { + continue; + } + + typeId = TypeId[x, y]; + if (TickSpark(x, y, typeId, ref seed)) + { + continue; + } + + typeId = TypeId[x, y]; + AutoIgnite(x, y, typeId); + ApplyRuntimeEmissionAndProduction(x, y, typeId, ref seed); + if (TickBurning(x, y, typeId, ref seed)) + { + continue; + } + + typeId = TypeId[x, y]; + if (TickSpecialBehavior(x, y, typeId, ref seed)) + { + continue; + } + + typeId = TypeId[x, y]; + ApplyPhaseTransitions(x, y, ref typeId); + if (typeId == 0) + { + continue; + } + + if (ApplyLocalReactions(x, y, ref typeId, ref seed)) + { + continue; + } + + typeId = TypeId[x, y]; + if (TickPressure(x, y, typeId, ref seed)) + { + continue; + } + + ApplyTemperatureDiffusion(x, y, typeId); + ApplyMovement(x, y, typeId, leftFirst, ref seed); + } + } + + if (_settings.OuterWall && _idWall != 0) + { + ApplyBoundaryWallsFast(); + } + + _deferVisualDirtyTracking = false; + MarkBoundsUnionDirty(hadPreStepBounds, preStepStartX, preStepStartY, preStepEndX, preStepEndY); + Frame++; + _activeStepToken = 0; + _staleOccupiedBoundsFrames = _boundsDirty ? _staleOccupiedBoundsFrames + 1 : 0; + FrameStats.Frame = Frame; + FrameStats.ProcessedCells = processedCells; + FrameStats.ParticleCount = ParticleCount; + FrameStats.MinActiveX = _hasOccupiedBounds ? _minOccupiedX : 0; + FrameStats.MinActiveY = _hasOccupiedBounds ? _minOccupiedY : 0; + FrameStats.MaxActiveX = _hasOccupiedBounds ? _maxOccupiedX : 0; + FrameStats.MaxActiveY = _hasOccupiedBounds ? _maxOccupiedY : 0; + FrameStats.LoadedChunkCount = 0; + FrameStats.ActiveChunkCount = 0; + FrameStats.DirtyChunkCount = 0; + FrameStats.SteppedChunkCount = 0; + FrameStats.SleepingChunkCount = 0; + FrameStats.FieldPageCount = 0; + FrameStats.MoveAttemptCount = 0; + FrameStats.VerticalMoveAttemptCount = 0; + FrameStats.DiagonalMoveAttemptCount = 0; + FrameStats.LateralMoveAttemptCount = 0; + FrameStats.SuccessfulMoveCount = 0; + FrameStats.SwapAttemptCount = 0; + FrameStats.StalledMovableCount = 0; + FrameStats.MovementOnlyFastPathCount = 0; + FrameStats.FullRuntimeStepCount = 0; + FrameStats.FullRuntimeSolidCount = 0; + FrameStats.FullRuntimeLiquidCount = 0; + FrameStats.FullRuntimeGasCount = 0; + FrameStats.MovedParticleCount = 0; + FrameStats.SwappedParticleCount = 0; + FrameStats.VisualDirtyPageCount = 0; + FrameStats.FrameBuildBytesTouched = 0; + FrameStats.ActivationTimeMicroseconds = 0; + FrameStats.MovementTimeMicroseconds = 0; + FrameStats.RuntimeTimeMicroseconds = 0; + FrameStats.FieldDecayTimeMicroseconds = 0; + FrameStats.RenderTimeMicroseconds = 0; + } + + public ReadOnlySpan BuildRgbFrame() + { + UpdateCachedVisualBuffers(); + return _rgbBuffer; + } + + public ReadOnlySpan BuildRgbaFrame() + { + UpdateCachedVisualBuffers(); + FrameStats.FrameBuildBytesTouched = (long)_rgbaBuffer.Length; + FrameStats.RenderTimeMicroseconds = 0; + return _rgbaBuffer; + } + + public void BuildRgbaFrame(Span destination) + { + var expectedLength = Width * Height * 4; + if (destination.Length < expectedLength) + { + throw new ArgumentException($"RGBA destination must be at least {expectedLength} bytes.", nameof(destination)); + } + + UpdateCachedVisualBuffers(); + _rgbaBuffer.AsSpan().CopyTo(destination); + FrameStats.FrameBuildBytesTouched = (long)_rgbaBuffer.Length; + FrameStats.RenderTimeMicroseconds = 0; + } + + private Rgb24 ResolveVisualColor(int x, int y) + { + var typeId = TypeId[x, y]; + if (typeId == 0) + { + if (_settings.EnablePressureVisuals) + { + var pressure = MathF.Abs(_airPressure[x, y]); + if (pressure > 0.08f) + { + var tint = (byte)Math.Clamp((int)(pressure * 32f), 10, 140); + if (_airPressure[x, y] >= 0f) + { + return new Rgb24((byte)(26 + tint), (byte)(18 + tint / 3), 24); + } + + return new Rgb24(18, (byte)(24 + tint / 4), (byte)(30 + tint)); + } + } + + if (_settings.EnableWindVisuals) + { + var totalFieldX = _windFieldX[x, y] + _forceFieldX[x, y]; + var totalFieldY = _windFieldY[x, y] + _forceFieldY[x, y]; + var windStrength = MathF.Sqrt((totalFieldX * totalFieldX) + (totalFieldY * totalFieldY)); + if (windStrength > 0.08f) + { + var tint = (byte)Math.Clamp((int)(windStrength * 40f), 10, 120); + return new Rgb24((byte)(16 + tint / 3), (byte)(24 + tint / 2), (byte)(32 + tint)); + } + } + + return new Rgb24(0, 0, 0); + } + + var color = SparkTime[x, y] > 0 ? new Rgb24(255, 255, 150) : _colorLut[typeId]; + if (_settings.EnableTempVisuals) + { + var delta = Temperature[x, y] - _settings.AmbientTemperature; + if (delta > 5f) + { + var heat = (byte)Math.Clamp((int)(delta * 4f), 0, 120); + color = new Rgb24( + (byte)Math.Min(255, color.R + heat), + (byte)Math.Max(0, color.G - heat / 3), + (byte)Math.Max(0, color.B - heat / 2)); + } + else if (delta < -5f) + { + var cool = (byte)Math.Clamp((int)(Math.Abs(delta) * 4f), 0, 120); + color = new Rgb24( + (byte)Math.Max(0, color.R - cool / 2), + (byte)Math.Max(0, color.G - cool / 3), + (byte)Math.Min(255, color.B + cool)); + } + } + + if (_settings.EnableGasEffect && _kind[typeId] == (byte)ParticleKind.Gas) + { + var bias = (byte)(Hash(x, y, Frame) & 0x0F); + color = new Rgb24( + (byte)Math.Min(255, color.R + bias), + (byte)Math.Min(255, color.G + bias), + (byte)Math.Min(255, color.B + bias)); + } + + if (_settings.EnableGlow && (Burning[x, y] != 0 || typeId == _idFire || typeId == _idLava || SparkTime[x, y] > 0)) + { + color = new Rgb24( + (byte)Math.Min(255, color.R + 25), + (byte)Math.Min(255, color.G + 18), + (byte)Math.Min(255, color.B + 12)); + } + + return color; + } + +} diff --git a/Sand.Core/SandSimulation.Thermal.cs b/Sand.Core/SandSimulation.Thermal.cs new file mode 100644 index 0000000..30c923c --- /dev/null +++ b/Sand.Core/SandSimulation.Thermal.cs @@ -0,0 +1,609 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + private bool TickLifetime(int x, int y, float dt) + { + var typeId = TypeId[x, y]; + if (typeId == 0) + { + return false; + } + + if (Lifetime[x, y] > 0f) + { + Lifetime[x, y] -= dt * 60f; + } + + if (Lifetime[x, y] <= 0f && _defaultLifetime[typeId] > 0f) + { + if (typeId == _idSteam && TryCondenseSteam(x, y, ref typeId)) + { + return true; + } + + ResetCell(x, y); + return true; + } + + return false; + } + + private bool TickSpark(int x, int y, ushort typeId, ref uint seed) + { + var spark = SparkTime[x, y]; + if (spark > 0) + { + SparkTime[x, y]--; + ForEachNeighbor8(x, y, (nx, ny) => + { + var neighborId = TypeId[nx, ny]; + if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f) && SparkTime[nx, ny] == 0) + { + SparkTime[nx, ny] = 4; + MarkProcessed(nx, ny); + } + }); + + if (typeId == _idSpark) + { + ResetCell(x, y); + return true; + } + + if (SparkTime[x, y] == 0) + { + SparkTime[x, y] = -10; + } + } + else if (spark < 0) + { + SparkTime[x, y]++; + } + + if (typeId != _idEnergy) + { + return false; + } + + TransferEnergy(x, y, typeId, ref seed, 0.3f); + return false; + } + + private void AutoIgnite(int x, int y, ushort typeId) + { + if (Burning[x, y] == 0 && _burnTemperature[typeId] > 0f && Temperature[x, y] >= _burnTemperature[typeId] && _flamability[typeId] > 0f) + { + Burning[x, y] = 1; + BurnTime[x, y] = MathF.Max(_burnDuration[typeId], 1f); + } + } + + private void ApplyRuntimeEmissionAndProduction(int x, int y, ushort typeId, ref uint seed) + { + var behavior = (ParticleBehaviorKind)_behaviorKind[typeId]; + var isEffectEmitter = + behavior == ParticleBehaviorKind.Fire || + behavior == ParticleBehaviorKind.BurningWood || + behavior == ParticleBehaviorKind.Ember || + behavior == ParticleBehaviorKind.Plasma; + + var emission = _heatEmission[typeId] * _heatEmissionMultiplier[typeId]; + if (behavior == ParticleBehaviorKind.Fire && emission <= 0f) + { + emission = 24f; + } + else if (behavior == ParticleBehaviorKind.Plasma && emission <= 0f) + { + emission = 80f; + } + else if (behavior == ParticleBehaviorKind.Ember && emission <= 0f) + { + emission = 12f; + } + else if (_isMolten[typeId] != 0 && emission <= 0f) + { + emission = typeId == _idLava ? 42f : 28f; + } + + if (emission > 0f) + { + ApplyHeatEmission(x, y, emission); + } + + var transfer = _energyTransfer[typeId] * _energyTransferMultiplier[typeId]; + if ((behavior == ParticleBehaviorKind.Plasma || typeId == _idEnergy) && transfer <= 0f) + { + transfer = 100f; + } + + if (transfer > 0f) + { + var chance = behavior == ParticleBehaviorKind.Plasma + ? 0.9f + : MathF.Min(0.85f, 0.15f + (transfer / 100f) * 0.55f); + TransferEnergy(x, y, typeId, ref seed, chance); + if (behavior == ParticleBehaviorKind.Plasma) + { + ForEachNeighbor8(x, y, (nx, ny) => + { + var neighborId = TypeId[nx, ny]; + if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f)) + { + SparkTime[nx, ny] = 4; + MarkProcessed(nx, ny); + } + }); + } + } + + if (isEffectEmitter || Burning[x, y] != 0) + { + TrySpawnProducedParticle(x, y, typeId, ref seed); + } + + if (_idSmoke != 0 && isEffectEmitter && _smokeSpawnChance[typeId] > 0f && NextChance(ref seed, _smokeSpawnChance[typeId])) + { + TrySpawnAtOffset(x, y, 0, -1, _idSmoke); + } + + if (_idEmber != 0 && isEffectEmitter && _emberSpawnChance[typeId] > 0f && NextChance(ref seed, _emberSpawnChance[typeId])) + { + TrySpawnAtOffset(x, y, 0, -1, _idEmber); + } + } + + private bool TickBurning(int x, int y, ushort typeId, ref uint seed) + { + if (Burning[x, y] == 0) + { + return false; + } + + Temperature[x, y] += 1.5f + (_heatEmission[typeId] * 0.02f); + BurnTime[x, y] -= MathF.Max(0.05f, _burnDecayPerStep[typeId] * _burnRate[typeId]); + + if (BurnTime[x, y] > 0f) + { + return false; + } + + ResetCell(x, y); + return true; + } + + private bool TickSpecialBehavior(int x, int y, ushort typeId, ref uint seed) + { + switch ((ParticleBehaviorKind)_behaviorKind[typeId]) + { + case ParticleBehaviorKind.Fire: + if (Temperature[x, y] < 120f && NextChance(ref seed, 0.1f)) + { + ResetCell(x, y); + return true; + } + break; + case ParticleBehaviorKind.Ember: + if (Temperature[x, y] < 60f && NextChance(ref seed, 0.1f)) + { + ResetCell(x, y); + return true; + } + break; + case ParticleBehaviorKind.BurningWood: + if (_idEmber != 0 && NextChance(ref seed, _emberSpawnChance[typeId] * 0.5f)) + { + TrySpawnAtOffset(x, y, 0, -1, _idEmber); + } + break; + } + + return false; + } + + private void ApplyPhaseTransitions(int x, int y, ref ushort typeId) + { + if (_phaseTransitionHysteresis[typeId] > 0f && _cellAge[x, y] < _phaseTransitionHysteresis[typeId]) + { + return; + } + + if (typeId == _idWater) + { + if (_idSteam != 0 && Temperature[x, y] >= 100f) + { + ReplaceCell(x, y, _idSteam); + typeId = TypeId[x, y]; + return; + } + + if (_idIce != 0 && Temperature[x, y] <= 0f) + { + ReplaceCell(x, y, _idIce); + typeId = TypeId[x, y]; + return; + } + } + + if (typeId == _idSteam && Temperature[x, y] <= 85f) + { + if (TryCondenseSteam(x, y, ref typeId)) + { + return; + } + } + + if ((typeId == _idIce || typeId == _idSnow) && _idWater != 0) + { + var meltPoint = typeId == _idIce ? 7f : 9f; + if (Temperature[x, y] >= meltPoint) + { + ReplaceCell(x, y, _idWater); + typeId = TypeId[x, y]; + return; + } + } + + if (_evapTarget[typeId] != 0 && Temperature[x, y] >= _evapTemperature[typeId]) + { + ReplaceCell(x, y, _evapTarget[typeId]); + typeId = TypeId[x, y]; + return; + } + + if (_meltTarget[typeId] != 0 && Temperature[x, y] >= _meltTemperature[typeId]) + { + ReplaceCell(x, y, _meltTarget[typeId]); + typeId = TypeId[x, y]; + return; + } + + if (_solidifyTarget[typeId] != 0 && Temperature[x, y] <= _solidifyTemperature[typeId]) + { + ReplaceCell(x, y, _solidifyTarget[typeId]); + typeId = TypeId[x, y]; + return; + } + + if (_freezeTarget[typeId] != 0 && Temperature[x, y] <= _freezeTemperature[typeId]) + { + ReplaceCell(x, y, _freezeTarget[typeId]); + typeId = TypeId[x, y]; + } + } + + private bool ApplyLocalReactions(int x, int y, ref ushort typeId, ref uint seed) + { + var neighbors = new (int X, int Y)[] + { + (x - 1, y), + (x + 1, y), + (x, y - 1), + (x, y + 1), + }; + + foreach (var (nx, ny) in neighbors) + { + var tx = nx; + var ty = ny; + if (!TryNormalizeCoordinate(ref tx, ref ty)) + { + continue; + } + + var neighborId = TypeId[tx, ty]; + if (neighborId == 0) + { + continue; + } + + if (typeId == _idWater && neighborId == _idSand && _idWetSand != 0) + { + ReplaceCell(tx, ty, _idWetSand); + ResetCell(x, y); + return true; + } + + if (typeId == _idWater && neighborId == _idDirt && _idMud != 0) + { + ReplaceCell(tx, ty, _idMud); + ResetCell(x, y); + return true; + } + + if (typeId == _idLava && neighborId == _idWater && _idStone != 0 && _idSteam != 0) + { + ReplaceCell(x, y, _idStone); + ReplaceCell(tx, ty, _idSteam); + typeId = TypeId[x, y]; + return true; + } + + if (IsFireLike(typeId) && IsWaterLike(neighborId)) + { + ResetCell(x, y); + if (_idSteam != 0 && NextChance(ref seed, 0.5f)) + { + ReplaceCell(tx, ty, _idSteam); + } + + return true; + } + + if ((IsFireLike(typeId) || typeId == _idLava) && neighborId != _idFire && neighborId != _idLava && _flamability[neighborId] > 0f && _idFire != 0) + { + var spreadChance = typeId == _idLava + ? (_fireSpreadChance[typeId] > 0f ? _fireSpreadChance[typeId] : 0.1f) + : MathF.Max(0.05f, _fireSpreadChance[typeId]); + if (NextChance(ref seed, spreadChance)) + { + ReplaceCell(tx, ty, _idFire); + } + } + + if (typeId == _idAcid && neighborId != _idAcid && neighborId != _idFire && neighborId != _idSmoke && neighborId != _idWall && neighborId != _idGlass) + { + if (NextChance(ref seed, 0.05f)) + { + ResetCell(tx, ty); + if (NextChance(ref seed, 0.2f)) + { + ResetCell(x, y); + return true; + } + } + } + } + + return false; + } + + private bool TickPressure(int x, int y, ushort typeId, ref uint seed) + { + if (_airPressure[x, y] == 0f && + _pressure[typeId] <= 0f && + _explosive[typeId] == 0 && + _pressureDuration[x, y] <= 0f && + _integrity[x, y] >= _durability[typeId]) + { + return false; + } + + var localPressure = MathF.Abs(_airPressure[x, y]) * _pressureSensitivity[typeId]; + localPressure += _pressure[typeId] * 0.1f; + + // Mechanical pressure should come from compression/impacts, not from heat alone. + // Keep a small thermal contribution only for explicitly explosive materials. + if (_explosive[typeId] != 0 && Temperature[x, y] > _settings.AmbientTemperature + 250f) + { + localPressure += (Temperature[x, y] - _settings.AmbientTemperature) * 0.0015f; + } + + var threshold = _pressureThreshold[typeId]; + if (_explosive[typeId] != 0 && threshold <= 0f) + { + threshold = 8f; + } + else if (threshold <= 0f) + { + threshold = 2.5f + (_hardness[typeId] * 4f) + (_pressureResistance[typeId] * 0.35f); + } + + threshold += _pressureTolerance[typeId] + _pressureResistance[typeId]; + + if (threshold <= 0f) + { + return false; + } + + if (localPressure >= threshold) + { + _pressureDuration[x, y] += 1f; + } + else + { + _pressureDuration[x, y] = MathF.Max(0f, _pressureDuration[x, y] - 1f); + var maxIntegrity = _durability[typeId]; + if (_integrity[x, y] < maxIntegrity) + { + _integrity[x, y] = MathF.Min(maxIntegrity, _integrity[x, y] + MathF.Max(0.2f, _hardness[typeId] * 0.1f)); + } + + return false; + } + + var overload = MathF.Max(0f, localPressure - threshold); + var damageScale = 1f / MathF.Max(0.35f, 0.4f + _hardness[typeId] + (_pressureResistance[typeId] * 0.1f)); + _integrity[x, y] -= MathF.Max(0.15f, 0.45f + overload) * damageScale; + + var requiredDuration = _pressureThresholdDuration[typeId] > 0 + ? _pressureThresholdDuration[typeId] + : Math.Max(2, (int)MathF.Ceiling(2f + (_hardness[typeId] * 2f))); + if (_pressureDuration[x, y] < requiredDuration && _integrity[x, y] > 0f) + { + return false; + } + + if (_explosive[typeId] != 0) + { + TriggerExplosion(x, y, typeId, ref seed); + ResetCell(x, y); + return true; + } + + if (_flamability[typeId] > 0f && Temperature[x, y] >= Math.Max(60f, _burnTemperature[typeId] * 0.5f)) + { + Burning[x, y] = 1; + BurnTime[x, y] = MathF.Max(1f, _burnDuration[typeId]); + return false; + } + + if (_integrity[x, y] <= 0f || overload >= MathF.Max(1.5f, threshold * 0.45f)) + { + return BreakCellFromPressure(x, y, typeId, ref seed); + } + + return false; + } + + private void ApplyHeatEmission(int x, int y, float amount) + { + Temperature[x, y] += amount * 0.08f; + ForEachNeighbor8(x, y, (nx, ny) => + { + Temperature[nx, ny] += amount * 0.02f; + }); + } + + private void TransferEnergy(int x, int y, ushort typeId, ref uint seed, float chance) + { + var localSeed = seed; + ForEachNeighbor8(x, y, (nx, ny) => + { + var neighborId = TypeId[nx, ny]; + if (neighborId != 0 && (_conductive[neighborId] != 0 || _conductivity[neighborId] > 0.5f) && SparkTime[nx, ny] == 0 && NextChance(ref localSeed, chance)) + { + SparkTime[nx, ny] = 4; + MarkProcessed(nx, ny); + } + else if (neighborId == 0 && _idSpark != 0 && NextChance(ref localSeed, MathF.Min(0.75f, chance * 0.5f))) + { + SetCell(nx, ny, _idSpark); + SparkTime[nx, ny] = 4; + } + }); + seed = localSeed; + } + + private void TrySpawnProducedParticle(int x, int y, ushort typeId, ref uint seed) + { + var produceType = _producesTarget[typeId]; + if (produceType == 0) + { + return; + } + + var chance = _produceChance[typeId]; + if (chance <= 0f) + { + chance = 0.08f; + } + + if (!NextChance(ref seed, chance)) + { + return; + } + + SpawnNeighborParticle(x, y, produceType, seed); + } + + private bool TrySpawnAtOffset(int x, int y, int offsetX, int offsetY, ushort typeId) + { + var tx = x + offsetX; + var ty = y + offsetY; + if (!TryNormalizeCoordinate(ref tx, ref ty)) + { + return false; + } + + if ((_settings.OuterWall && IsBoundary(tx, ty)) || TypeId[tx, ty] != 0) + { + return false; + } + + SetCell(tx, ty, typeId); + return true; + } + + private bool IsFireLike(ushort typeId) + { + var behavior = (ParticleBehaviorKind)_behaviorKind[typeId]; + return typeId == _idFire || behavior == ParticleBehaviorKind.Fire || behavior == ParticleBehaviorKind.Ember || behavior == ParticleBehaviorKind.Plasma; + } + + private bool IsWaterLike(ushort typeId) + { + return typeId == _idWater || typeId == _idWetSand || typeId == _idMud; + } + + private bool TryCondenseSteam(int x, int y, ref ushort typeId) + { + if (_idWater == 0) + { + return false; + } + + var condensedTemperature = MathF.Max(_initialTemperature[_idWater], MathF.Min(Temperature[x, y], 95f)); + ReplaceCell(x, y, _idWater); + Temperature[x, y] = condensedTemperature; + typeId = TypeId[x, y]; + return true; + } + + private void ApplyTemperatureDiffusion(int x, int y, ushort typeId) + { + var conductivity = MathF.Max(_conductivity[typeId], 0.01f); + var localTemperature = Temperature[x, y]; + var airDelta = localTemperature - _settings.AmbientTemperature; + if (_settings.FastSim && MathF.Abs(airDelta) <= 0.5f) + { + var rightX = x + 1; + var rightY = y; + var downX = x; + var downY = y + 1; + var rightStable = + !TryNormalizeCoordinate(ref rightX, ref rightY) || + TypeId[rightX, rightY] == 0 || + MathF.Abs(localTemperature - Temperature[rightX, rightY]) <= 0.5f; + var downStable = + !TryNormalizeCoordinate(ref downX, ref downY) || + TypeId[downX, downY] == 0 || + MathF.Abs(localTemperature - Temperature[downX, downY]) <= 0.5f; + + if (rightStable && downStable) + { + return; + } + } + + if (MathF.Abs(airDelta) > 0.5f) + { + var transfer = Clamp( + airDelta * ((conductivity + _settings.AirConductivity) * 0.5f) * 0.1f * _ambientCoolingMultiplier[typeId] / _heatCapacity[typeId], + airDelta * -0.5f, + airDelta * 0.5f); + Temperature[x, y] -= transfer; + } + + DiffuseBetween(x, y, x + 1, y, conductivity, _neighborHeatTransferMultiplier[typeId]); + DiffuseBetween(x, y, x, y + 1, conductivity, _neighborHeatTransferMultiplier[typeId]); + } + + private void DiffuseBetween(int x, int y, int nx, int ny, float conductivity, float multiplier) + { + if (!TryNormalizeCoordinate(ref nx, ref ny)) + { + return; + } + + var neighborId = TypeId[nx, ny]; + if (neighborId == 0) + { + return; + } + + var neighborConductivity = MathF.Max(_conductivity[neighborId], 0.1f); + var delta = Temperature[x, y] - Temperature[nx, ny]; + if (MathF.Abs(delta) <= 0.5f) + { + return; + } + + var transfer = Clamp( + delta * ((conductivity + neighborConductivity) * 0.5f) * 0.1f * multiplier / MathF.Max(0.1f, _heatCapacity[TypeId[x, y]]), + delta * -0.5f, + delta * 0.5f); + Temperature[x, y] -= transfer; + Temperature[nx, ny] += transfer; + } +} diff --git a/Sand.Core/SandSimulation.cs b/Sand.Core/SandSimulation.cs new file mode 100644 index 0000000..e8d97cb --- /dev/null +++ b/Sand.Core/SandSimulation.cs @@ -0,0 +1,347 @@ +namespace Sand.Core; + +public sealed partial class SandSimulation +{ + private readonly ParticleLibrary _library; + private readonly SimulationSettings _settings; + private readonly byte[] _rgbBuffer; + private readonly byte[] _rgbaBuffer; + private readonly byte[] _kind; + private readonly byte[] _isStatic; + private readonly float[] _mass; + private readonly float[] _hardness; + private readonly byte[] _isMolten; + private readonly float[] _velocity; + private readonly float[] _conductivity; + private readonly byte[] _conductive; + private readonly float[] _flamability; + private readonly float[] _durability; + private readonly float[] _heatCapacity; + private readonly float[] _friction; + private readonly float[] _viscosity; + private readonly float[] _pressure; + private readonly byte[] _burningInit; + private readonly float[] _burnDuration; + private readonly float[] _burnTemperature; + private readonly float[] _burnRate; + private readonly float[] _initialTemperature; + private readonly ushort[] _evapTarget; + private readonly float[] _evapTemperature; + private readonly ushort[] _meltTarget; + private readonly float[] _meltTemperature; + private readonly ushort[] _solidifyTarget; + private readonly float[] _solidifyTemperature; + private readonly ushort[] _freezeTarget; + private readonly float[] _freezeTemperature; + private readonly float[] _defaultLifetime; + private readonly ushort[] _producesTarget; + private readonly ushort[] _producesOnDeathTarget; + private readonly float[] _heatEmission; + private readonly float[] _energyTransfer; + private readonly float[] _radius; + private readonly float[] _forceFalloff; + private readonly float[] _turbulence; + private readonly float[] _pressureResistance; + private readonly float[] _pressureTolerance; + private readonly float[] _pressureThreshold; + private readonly short[] _pressureThresholdDuration; + private readonly ushort[] _brokenTarget; + private readonly byte[] _explosive; + private readonly short[] _explosionRadius; + private readonly float[] _explosionForce; + private readonly Rgb24?[] _explosionColor; + private readonly byte[] _behaviorKind; + private readonly float[] _lifetimeMultiplier; + private readonly float[] _burnDecayPerStep; + private readonly float[] _ambientCoolingMultiplier; + private readonly float[] _neighborHeatTransferMultiplier; + private readonly float[] _upwardBias; + private readonly float[] _sideDriftBias; + private readonly float[] _fireSpreadChance; + private readonly float[] _smokeSpawnChance; + private readonly float[] _emberSpawnChance; + private readonly float[] _produceChance; + private readonly float[] _heatEmissionMultiplier; + private readonly float[] _energyTransferMultiplier; + private readonly float[] _pressureSensitivity; + private readonly float[] _pressureDecayMultiplier; + private readonly float[] _forceResponseMultiplier; + private readonly float[] _lateralFlowMultiplier; + private readonly float[] _diagonalFlowMultiplier; + private readonly float[] _phaseTransitionHysteresis; + private readonly float[] _minLifetimeTicks; + private readonly float[] _maxLifetimeTicks; + private readonly Rgb24[] _colorLut; + private readonly float[,] _windFieldX; + private readonly float[,] _windFieldY; + private readonly float[,] _forceFieldX; + private readonly float[,] _forceFieldY; + private readonly float[,] _airPressure; + private readonly float[,] _pressureDuration; + private readonly float[,] _cellAge; + private readonly float[,] _integrity; + private readonly ToolProfile _windTool; + private readonly ToolProfile _gravityTool; + private readonly ToolProfile _repulsorTool; + private readonly ToolProfile _airTool; + private readonly ushort _idFire; + private readonly ushort _idLava; + private readonly ushort _idWater; + private readonly ushort _idSand; + private readonly ushort _idWetSand; + private readonly ushort _idDirt; + private readonly ushort _idMud; + private readonly ushort _idStone; + private readonly ushort _idSteam; + private readonly ushort _idAcid; + private readonly ushort _idSpark; + private readonly ushort _idEnergy; + private readonly ushort _idWall; + private readonly ushort _idSmoke; + private readonly ushort _idGlass; + private readonly ushort _idBurningWood; + private readonly ushort _idEmber; + private readonly ushort _idPlasma; + private readonly ushort _idSnow; + private readonly ushort _idIce; + private readonly int[,] _processedFrame; + private readonly int[,] _explosionFrame; + private int _activeStepToken; + private bool _boundsDirty; + private int _staleOccupiedBoundsFrames; + private bool _hasOccupiedBounds; + private int _minOccupiedX; + private int _minOccupiedY; + private int _maxOccupiedX; + private int _maxOccupiedY; + private bool _fieldBoundsDirty; + private bool _hasActiveFieldBounds; + private int _minActiveFieldX; + private int _minActiveFieldY; + private int _maxActiveFieldX; + private int _maxActiveFieldY; + private bool _hasDirtyVisualBounds; + private bool _fullVisualDirty = true; + private int _minDirtyVisualX; + private int _minDirtyVisualY; + private int _maxDirtyVisualX; + private int _maxDirtyVisualY; + private bool _deferVisualDirtyTracking; + private uint _visualSettingsStamp = uint.MaxValue; + + public SandSimulation(int width, int height, int particleSize, IParticleLibrary library, SimulationSettings? settings = null) + { + Width = width; + Height = height; + ParticleSize = particleSize; + _library = library as ParticleLibrary ?? throw new ArgumentException("Expected ParticleLibrary implementation.", nameof(library)); + _settings = settings ?? new SimulationSettings(); + + TypeId = new ushort[width, height]; + Temperature = new float[width, height]; + BurnTime = new float[width, height]; + Burning = new byte[width, height]; + SparkTime = new short[width, height]; + Lifetime = new float[width, height]; + _pressureDuration = new float[width, height]; + _cellAge = new float[width, height]; + _windFieldX = new float[width, height]; + _windFieldY = new float[width, height]; + _forceFieldX = new float[width, height]; + _forceFieldY = new float[width, height]; + _airPressure = new float[width, height]; + _processedFrame = new int[width, height]; + _explosionFrame = new int[width, height]; + _integrity = new float[width, height]; + _rgbBuffer = new byte[width * height * 3]; + _rgbaBuffer = new byte[width * height * 4]; + FrameStats = new SimulationFrameStats(); + var count = _library.Definitions.Count + 1; + _kind = new byte[count]; + _isStatic = new byte[count]; + _mass = new float[count]; + _hardness = new float[count]; + _isMolten = new byte[count]; + _velocity = new float[count]; + _conductivity = new float[count]; + _conductive = new byte[count]; + _flamability = new float[count]; + _durability = new float[count]; + _heatCapacity = new float[count]; + _friction = new float[count]; + _viscosity = new float[count]; + _pressure = new float[count]; + _burningInit = new byte[count]; + _burnDuration = new float[count]; + _burnTemperature = new float[count]; + _burnRate = new float[count]; + _initialTemperature = new float[count]; + _evapTarget = new ushort[count]; + _evapTemperature = Enumerable.Repeat(9999f, count).ToArray(); + _meltTarget = new ushort[count]; + _meltTemperature = Enumerable.Repeat(9999f, count).ToArray(); + _solidifyTarget = new ushort[count]; + _solidifyTemperature = Enumerable.Repeat(-9999f, count).ToArray(); + _freezeTarget = new ushort[count]; + _freezeTemperature = Enumerable.Repeat(-9999f, count).ToArray(); + _defaultLifetime = new float[count]; + _producesTarget = new ushort[count]; + _producesOnDeathTarget = new ushort[count]; + _heatEmission = new float[count]; + _energyTransfer = new float[count]; + _radius = new float[count]; + _forceFalloff = new float[count]; + _turbulence = new float[count]; + _pressureResistance = new float[count]; + _pressureTolerance = new float[count]; + _pressureThreshold = new float[count]; + _pressureThresholdDuration = new short[count]; + _brokenTarget = new ushort[count]; + _explosive = new byte[count]; + _explosionRadius = new short[count]; + _explosionForce = new float[count]; + _explosionColor = new Rgb24?[count]; + _behaviorKind = new byte[count]; + _lifetimeMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _burnDecayPerStep = Enumerable.Repeat(1f, count).ToArray(); + _ambientCoolingMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _neighborHeatTransferMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _upwardBias = new float[count]; + _sideDriftBias = new float[count]; + _fireSpreadChance = new float[count]; + _smokeSpawnChance = new float[count]; + _emberSpawnChance = new float[count]; + _produceChance = new float[count]; + _heatEmissionMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _energyTransferMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _pressureSensitivity = Enumerable.Repeat(1f, count).ToArray(); + _pressureDecayMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _forceResponseMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _lateralFlowMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _diagonalFlowMultiplier = Enumerable.Repeat(1f, count).ToArray(); + _phaseTransitionHysteresis = new float[count]; + _minLifetimeTicks = new float[count]; + _maxLifetimeTicks = new float[count]; + _colorLut = new Rgb24[count]; + + for (ushort typeId = 1; typeId < count; typeId++) + { + var definition = _library.GetDefinition(typeId); + var runtime = ParticleRuntimeProfileBuilder.Build(definition); + _kind[typeId] = (byte)definition.Kind; + _isStatic[typeId] = definition.IsStatic ? (byte)1 : (byte)0; + _mass[typeId] = definition.Mass; + _hardness[typeId] = MathF.Max(0f, definition.Hardness); + _isMolten[typeId] = definition.Id == "lava" || definition.Id.StartsWith("molten_", StringComparison.Ordinal) ? (byte)1 : (byte)0; + _velocity[typeId] = definition.Velocity; + _conductivity[typeId] = definition.Conductivity; + _conductive[typeId] = definition.Conductive ? (byte)1 : (byte)0; + _flamability[typeId] = definition.Flamability; + _durability[typeId] = MathF.Max(1f, definition.Durability); + _heatCapacity[typeId] = MathF.Max(0.1f, definition.HeatCapacity); + _friction[typeId] = definition.Friction; + _viscosity[typeId] = definition.Viscosity; + _pressure[typeId] = definition.Pressure; + _burningInit[typeId] = definition.Burning ? (byte)1 : (byte)0; + _burnDuration[typeId] = definition.BurnDuration; + _burnTemperature[typeId] = definition.BurnTemperature; + _burnRate[typeId] = MathF.Max(0.05f, definition.BurnRate); + _initialTemperature[typeId] = definition.Temperature; + _evapTarget[typeId] = ResolveOptionalTypeId(definition.Evaporate); + _meltTarget[typeId] = ResolveOptionalTypeId(definition.Melt); + _solidifyTarget[typeId] = ResolveOptionalTypeId(definition.Solidify); + _freezeTarget[typeId] = ResolveOptionalTypeId(definition.Freeze); + _producesTarget[typeId] = ResolveOptionalTypeId(definition.Produces); + _producesOnDeathTarget[typeId] = ResolveOptionalTypeId(definition.ProducesOnDeath); + _heatEmission[typeId] = definition.HeatEmission; + _energyTransfer[typeId] = definition.EnergyTransfer; + _radius[typeId] = definition.Radius; + _forceFalloff[typeId] = definition.ForceFalloff <= 0f ? 1f : definition.ForceFalloff; + _turbulence[typeId] = definition.Turbulence; + _pressureResistance[typeId] = definition.PressureResistance; + _pressureTolerance[typeId] = definition.PressureTolerance; + _pressureThreshold[typeId] = definition.PressureThreshold; + _pressureThresholdDuration[typeId] = checked((short)Math.Clamp(definition.PressureThresholdDuration, 0, short.MaxValue)); + _brokenTarget[typeId] = ResolveOptionalTypeId(definition.Broken); + _explosive[typeId] = definition.Explosive ? (byte)1 : (byte)0; + _explosionRadius[typeId] = checked((short)Math.Clamp(definition.ExplosionRadius, 0, short.MaxValue)); + _explosionForce[typeId] = definition.ExplosionForce; + _explosionColor[typeId] = definition.ExplosionColor; + if (definition.EvaporateTemperature is not null) _evapTemperature[typeId] = definition.EvaporateTemperature.Value; + if (definition.MeltTemperature is not null) _meltTemperature[typeId] = definition.MeltTemperature.Value; + if (definition.SolidifyTemperature is not null) _solidifyTemperature[typeId] = definition.SolidifyTemperature.Value; + if (definition.FreezeTemperature is not null) _freezeTemperature[typeId] = definition.FreezeTemperature.Value; + _behaviorKind[typeId] = (byte)runtime.Balance.BehaviorKind; + _lifetimeMultiplier[typeId] = runtime.Balance.LifetimeMultiplier; + _burnDecayPerStep[typeId] = runtime.Balance.BurnDecayPerStep; + _ambientCoolingMultiplier[typeId] = runtime.Balance.AmbientCoolingMultiplier; + _neighborHeatTransferMultiplier[typeId] = runtime.Balance.NeighborHeatTransferMultiplier; + _upwardBias[typeId] = runtime.Balance.UpwardBias; + _sideDriftBias[typeId] = runtime.Balance.SideDriftBias; + _fireSpreadChance[typeId] = runtime.Balance.FireSpreadChance; + _smokeSpawnChance[typeId] = runtime.Balance.SmokeSpawnChance; + _emberSpawnChance[typeId] = runtime.Balance.EmberSpawnChance; + _produceChance[typeId] = runtime.Balance.ProduceChance; + _heatEmissionMultiplier[typeId] = runtime.Balance.HeatEmissionMultiplier; + _energyTransferMultiplier[typeId] = runtime.Balance.EnergyTransferMultiplier; + _pressureSensitivity[typeId] = runtime.Balance.PressureSensitivity; + _pressureDecayMultiplier[typeId] = runtime.Balance.PressureDecayMultiplier; + _forceResponseMultiplier[typeId] = runtime.Balance.ForceResponseMultiplier; + _lateralFlowMultiplier[typeId] = runtime.Balance.LateralFlowMultiplier; + _diagonalFlowMultiplier[typeId] = runtime.Balance.DiagonalFlowMultiplier; + _phaseTransitionHysteresis[typeId] = runtime.Balance.PhaseTransitionHysteresis; + _minLifetimeTicks[typeId] = runtime.Balance.MinLifetimeTicks; + _maxLifetimeTicks[typeId] = runtime.Balance.MaxLifetimeTicks; + _defaultLifetime[typeId] = ResolveDefaultLifetime(definition, runtime.Balance); + _colorLut[typeId] = definition.Color; + } + + _idFire = ResolveOptionalTypeId("fire"); + _idLava = ResolveOptionalTypeId("lava"); + _idWater = ResolveOptionalTypeId("water"); + _idSand = ResolveOptionalTypeId("sand"); + _idWetSand = ResolveOptionalTypeId("wsand"); + _idDirt = ResolveOptionalTypeId("dirt"); + _idMud = ResolveOptionalTypeId("mud"); + _idStone = ResolveOptionalTypeId("stone"); + _idSteam = ResolveOptionalTypeId("steam"); + _idAcid = ResolveOptionalTypeId("acid"); + _idSpark = ResolveOptionalTypeId("spark"); + _idEnergy = ResolveOptionalTypeId("energy"); + _idWall = ResolveOptionalTypeId("wall"); + _idSmoke = ResolveOptionalTypeId("smoke"); + _idGlass = ResolveOptionalTypeId("glass"); + _idBurningWood = ResolveOptionalTypeId("burning_wood"); + _idEmber = ResolveOptionalTypeId("ember"); + _idPlasma = ResolveOptionalTypeId("plasma"); + _idSnow = ResolveOptionalTypeId("snow"); + _idIce = ResolveOptionalTypeId("ice"); + if (_idBurningWood != 0 && _producesOnDeathTarget[_idBurningWood] == 0 && _idEmber != 0) + { + _producesOnDeathTarget[_idBurningWood] = _idEmber; + } + _windTool = BuildToolProfile("wind", defaultStrength: 4f, fallbackRadiusCells: 4); + _gravityTool = BuildToolProfile("gravity_well", defaultStrength: 3.5f, fallbackRadiusCells: 6); + _repulsorTool = BuildToolProfile("repulsor", defaultStrength: 3f, fallbackRadiusCells: 5); + _airTool = new ToolProfile("air", 4, 3f, 1f, 0.1f, ["all"]); + + Clear(); + } + + public int Width { get; } + public int Height { get; } + public int ParticleSize { get; } + public int Frame { get; private set; } + public int ParticleCount { get; private set; } + public ushort[,] TypeId { get; } + public float[,] Temperature { get; } + public float[,] BurnTime { get; } + public byte[,] Burning { get; } + public short[,] SparkTime { get; } + public float[,] Lifetime { get; } + public SimulationSettings Settings => _settings; + public SimulationFrameStats FrameStats { get; } + public ISimulationAccelerator? Accelerator { get; set; } + public float GetPressureDurationAtCell(int x, int y) => _pressureDuration[x, y]; +} + diff --git a/Sand.Core/SimulationFrameStats.cs b/Sand.Core/SimulationFrameStats.cs new file mode 100644 index 0000000..2932913 --- /dev/null +++ b/Sand.Core/SimulationFrameStats.cs @@ -0,0 +1,39 @@ +namespace Sand.Core; + +public sealed class SimulationFrameStats +{ + public int Frame { get; internal set; } + public int ProcessedCells { get; internal set; } + public int ParticleCount { get; internal set; } + public int MinActiveX { get; internal set; } + public int MinActiveY { get; internal set; } + public int MaxActiveX { get; internal set; } + public int MaxActiveY { get; internal set; } + public int LoadedChunkCount { get; internal set; } + public int ActiveChunkCount { get; internal set; } + public int DirtyChunkCount { get; internal set; } + public int SteppedChunkCount { get; internal set; } + public int SleepingChunkCount { get; internal set; } + public int FieldPageCount { get; internal set; } + public int MoveAttemptCount { get; internal set; } + public int VerticalMoveAttemptCount { get; internal set; } + public int DiagonalMoveAttemptCount { get; internal set; } + public int LateralMoveAttemptCount { get; internal set; } + public int SuccessfulMoveCount { get; internal set; } + public int SwapAttemptCount { get; internal set; } + public int StalledMovableCount { get; internal set; } + public int MovementOnlyFastPathCount { get; internal set; } + public int FullRuntimeStepCount { get; internal set; } + public int FullRuntimeSolidCount { get; internal set; } + public int FullRuntimeLiquidCount { get; internal set; } + public int FullRuntimeGasCount { get; internal set; } + public int MovedParticleCount { get; internal set; } + public int SwappedParticleCount { get; internal set; } + public int VisualDirtyPageCount { get; internal set; } + public long FrameBuildBytesTouched { get; internal set; } + public long ActivationTimeMicroseconds { get; internal set; } + public long MovementTimeMicroseconds { get; internal set; } + public long RuntimeTimeMicroseconds { get; internal set; } + public long FieldDecayTimeMicroseconds { get; internal set; } + public long RenderTimeMicroseconds { get; internal set; } +} diff --git a/Sand.Core/SimulationSettings.cs b/Sand.Core/SimulationSettings.cs new file mode 100644 index 0000000..112fcf1 --- /dev/null +++ b/Sand.Core/SimulationSettings.cs @@ -0,0 +1,25 @@ +namespace Sand.Core; + +public sealed class SimulationSettings +{ + public SimulationStorageMode StorageMode { get; set; } = SimulationStorageMode.Dense; + public bool PauseSim { get; set; } + public bool EnableCursor { get; set; } = true; + public bool EnableGlow { get; set; } + public bool EnableGasEffect { get; set; } = true; + public bool EnableDebug { get; set; } = true; + public bool EnableFps { get; set; } = true; + public bool EnableWindVisuals { get; set; } + public bool EnablePressureVisuals { get; set; } + public bool EnableTempVisuals { get; set; } + public bool OuterWall { get; set; } + public bool EnableAcceleration { get; set; } + public bool FastSim { get; set; } = true; + public bool WrapParticles { get; set; } + public float TimeScale { get; set; } = 1f; + public float SimulationStepsPerSecond { get; set; } = 60f; + public float AmbientTemperature { get; set; } = 22f; + public float AirConductivity { get; set; } = 0.05f; + public float WindX { get; set; } + public float WindY { get; set; } +} diff --git a/Sand.Core/SimulationStorageMode.cs b/Sand.Core/SimulationStorageMode.cs new file mode 100644 index 0000000..5d597c5 --- /dev/null +++ b/Sand.Core/SimulationStorageMode.cs @@ -0,0 +1,7 @@ +namespace Sand.Core; + +public enum SimulationStorageMode +{ + Dense = 0, + ChunkPrototype = 1, +} diff --git a/Sand.Core/ToolProfile.cs b/Sand.Core/ToolProfile.cs new file mode 100644 index 0000000..e9b0bd0 --- /dev/null +++ b/Sand.Core/ToolProfile.cs @@ -0,0 +1,9 @@ +namespace Sand.Core; + +internal readonly record struct ToolProfile( + string Id, + int RadiusCells, + float Strength, + float Falloff, + float Turbulence, + string[] Affects); diff --git a/Sand.Experimental.slnx b/Sand.Experimental.slnx new file mode 100644 index 0000000..1fdbaeb --- /dev/null +++ b/Sand.Experimental.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Sand.Tests/ParticleLibraryLoaderTests.cs b/Sand.Tests/ParticleLibraryLoaderTests.cs new file mode 100644 index 0000000..51e4eaa --- /dev/null +++ b/Sand.Tests/ParticleLibraryLoaderTests.cs @@ -0,0 +1,31 @@ +using FluentAssertions; +using Sand.Core; + +namespace Sand.Tests; + +public sealed class ParticleLibraryLoaderTests +{ + [Fact] + public void LoadFromDirectory_LoadsCoreDefinitions() + { + var library = ParticleLibraryLoader.LoadFromDirectory(GetPartRoot()); + + library.Definitions.Should().NotBeEmpty(); + library.GetTypeId("sand").Should().BeGreaterThan((ushort)0); + library.GetDefinition(library.GetTypeId("water")).Name.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void LoadFromDirectory_NormalizesIdsAndColors() + { + var library = ParticleLibraryLoader.LoadFromDirectory(GetPartRoot()); + var stone = library.GetDefinition(library.GetTypeId("stone")); + + stone.Id.Should().Be("stone"); + stone.Color.R.Should().Be(128); + stone.Color.G.Should().Be(128); + stone.Color.B.Should().Be(128); + } + + private static string GetPartRoot() => Path.Combine(AppContext.BaseDirectory, "Content", "part"); +} diff --git a/Sand.Tests/Sand.Tests.csproj b/Sand.Tests/Sand.Tests.csproj new file mode 100644 index 0000000..1aca005 --- /dev/null +++ b/Sand.Tests/Sand.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sand.Tests/SandSimulationTests.cs b/Sand.Tests/SandSimulationTests.cs new file mode 100644 index 0000000..f17312f --- /dev/null +++ b/Sand.Tests/SandSimulationTests.cs @@ -0,0 +1,920 @@ +using FluentAssertions; +using Sand.Core; + +namespace Sand.Tests; + +public sealed class SandSimulationTests +{ + private readonly ParticleLibrary _library = ParticleLibraryLoader.LoadFromDirectory(Path.Combine(AppContext.BaseDirectory, "Content", "part")); + + [Fact] + public void SandFallsIntoEmptySpace() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(2, 2, "sand"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(2, 3).Should().Be(_library.GetTypeId("sand")); + } + + [Fact] + public void WaterAndSandCreateWetSand() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(3, 3, "sand"); + simulation.CreateParticleAtPixel(2, 3, "water"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(3, 3).Should().Be(_library.GetTypeId("wsand")); + } + + [Fact] + public void LoaderParsesExtendedMetadataFields() + { + var burningWood = _library.GetDefinition(_library.GetTypeId("burning_wood")); + var energy = _library.GetDefinition(_library.GetTypeId("energy")); + var wind = _library.GetDefinition(_library.GetTypeId("wind")); + var stone = _library.GetDefinition(_library.GetTypeId("stone")); + var ultratanium = _library.GetDefinition(_library.GetTypeId("ultratanium")); + + burningWood.Produces.Should().Be("smoke"); + burningWood.HeatEmission.Should().Be(50f); + energy.EnergyTransfer.Should().BeGreaterThan(0f); + wind.Radius.Should().BeGreaterThan(0f); + wind.Affects.Should().Contain("steam"); + stone.Hardness.Should().Be(0.7f); + stone.Durability.Should().Be(100f); + stone.Broken.Should().Be("brkstone"); + ultratanium.Explosive.Should().BeTrue(); + ultratanium.ExplosionRadius.Should().Be(15); + } + + [Fact] + public void LavaAndWaterCreateStoneAndSteam() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(2, 2, "lava"); + simulation.CreateParticleAtPixel(3, 2, "water"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(2, 2).Should().Be(_library.GetTypeId("stone")); + simulation.GetTypeIdAtCell(3, 2).Should().Be(_library.GetTypeId("steam")); + } + + [Fact] + public void BuildRgbFrameReflectsParticleColor() + { + var simulation = CreateSimulation(); + var sand = _library.GetDefinition(_library.GetTypeId("sand")); + simulation.CreateParticleAtPixel(0, 0, "sand"); + + var frame = simulation.BuildRgbFrame().ToArray(); + + frame[0].Should().Be(sand.Color.R); + frame[1].Should().Be(sand.Color.G); + frame[2].Should().Be(sand.Color.B); + } + + [Fact] + public void SparkSpreadsToDiagonalConductors() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 4, "iron"); + simulation.CreateParticleAtPixel(5, 5, "iron"); + simulation.CreateParticleAtPixel(4, 4, "spark"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.SparkTime[5, 5].Should().BeGreaterThan((short)0); + } + + [Fact] + public void OuterWallPreventsPaintingBoundaryCells() + { + var simulation = new SandSimulation(10, 10, 1, _library, new SimulationSettings { PauseSim = true, OuterWall = true }); + simulation.RefreshSettingsState(); + + simulation.CreateParticleAtPixel(0, 0, "sand"); + + simulation.GetTypeIdAtCell(0, 0).Should().Be(_library.GetTypeId("wall")); + } + + [Fact] + public void TogglingOuterWallOffRemovesBoundaryWallsFromParticleCount() + { + var settings = new SimulationSettings { PauseSim = true, OuterWall = true }; + var simulation = new SandSimulation(10, 10, 1, _library, settings); + simulation.RefreshSettingsState(); + simulation.ParticleCount.Should().BeGreaterThan(0); + + settings.OuterWall = false; + simulation.RefreshSettingsState(); + + simulation.ParticleCount.Should().Be(0); + simulation.GetTypeIdAtCell(0, 0).Should().Be(0); + simulation.GetTypeIdAtCell(9, 9).Should().Be(0); + } + + [Fact] + public void WrapParticlesMovesAcrossEdge() + { + var simulation = new SandSimulation(5, 5, 1, _library, new SimulationSettings { PauseSim = false, WrapParticles = true }); + simulation.CreateParticleAtPixel(4, 0, "steam"); + + simulation.Step(1f / 60f); + + simulation.ParticleCount.Should().Be(1); + } + + [Fact] + public void ParticleCountRemainsStableAcrossMovementSteps() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(6, 2, "sand"); + + for (var i = 0; i < 6; i++) + { + simulation.Step(1f / 60f); + simulation.ParticleCount.Should().Be(1); + } + } + + [Fact] + public void SymmetricSandPileDoesNotDevelopStrongRightBias() + { + var simulation = new SandSimulation(60, 40, 1, _library, new SimulationSettings { PauseSim = false }); + for (var x = 0; x < simulation.Width; x++) + { + simulation.CreateParticleAtPixel(x, simulation.Height - 1, "wall"); + } + + for (var x = 24; x <= 36; x++) + { + for (var y = 4; y <= 16; y++) + { + simulation.CreateParticleAtPixel(x, y, "sand"); + } + } + + var startCenter = FindAverageX(simulation, "sand"); + for (var i = 0; i < 120; i++) + { + simulation.Step(1f / 60f); + } + + var finalCenter = FindAverageX(simulation, "sand"); + Math.Abs(finalCenter - startCenter).Should().BeLessThanOrEqualTo(1.5); + } + + [Fact] + public void NeutralFireCanMoveLeftWhenOnlyLeftEscapePathIsAvailable() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(5, 5, "fire"); + simulation.CreateParticleAtPixel(5, 4, "wall"); + simulation.CreateParticleAtPixel(6, 4, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + var fireId = _library.GetTypeId("fire"); + (simulation.GetTypeIdAtCell(4, 4) == fireId || simulation.GetTypeIdAtCell(4, 5) == fireId).Should().BeTrue(); + } + + [Fact] + public void LazyOccupiedBoundsShrinkEventuallyAfterEdgeRemoval() + { + var simulation = new SandSimulation(30, 30, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(2, 2, "wall"); + simulation.CreateParticleAtPixel(20, 20, "wall"); + simulation.Step(1f / 60f); + + simulation.ClearParticleCircle(2, 2, 0); + + for (var i = 0; i < 10; i++) + { + simulation.Step(1f / 60f); + } + + simulation.FrameStats.ParticleCount.Should().Be(1); + simulation.FrameStats.MinActiveX.Should().BeLessThanOrEqualTo(20); + simulation.FrameStats.MaxActiveX.Should().BeGreaterThanOrEqualTo(20); + simulation.FrameStats.MaxActiveX.Should().BeLessThanOrEqualTo(21); + simulation.FrameStats.MinActiveY.Should().BeLessThanOrEqualTo(20); + simulation.FrameStats.MaxActiveY.Should().BeGreaterThanOrEqualTo(20); + simulation.FrameStats.MaxActiveY.Should().BeLessThanOrEqualTo(21); + } + + [Fact] + public void EmptySimulationClearsActiveBoundsStats() + { + var simulation = new SandSimulation(20, 20, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(10, 10, "sand"); + + simulation.Step(1f / 60f); + simulation.Clear(); + simulation.Settings.PauseSim = false; + simulation.Step(1f / 60f); + + simulation.FrameStats.ProcessedCells.Should().Be(0); + simulation.FrameStats.ParticleCount.Should().Be(0); + simulation.FrameStats.MinActiveX.Should().Be(0); + simulation.FrameStats.MinActiveY.Should().Be(0); + simulation.FrameStats.MaxActiveX.Should().Be(0); + simulation.FrameStats.MaxActiveY.Should().Be(0); + } + + [Fact] + public void BuildRgbaFrameWritesExpectedLength() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(0, 0, "sand"); + var buffer = new byte[10 * 10 * 4]; + + simulation.BuildRgbaFrame(buffer); + + buffer.Length.Should().Be(400); + buffer[3].Should().Be(255); + } + + [Fact] + public void BuildRgbaFrameUpdatesReusedBufferAfterCellChanges() + { + var simulation = CreateSimulation(); + var buffer = new byte[10 * 10 * 4]; + var sand = _library.GetDefinition(_library.GetTypeId("sand")); + + simulation.CreateParticleAtPixel(2, 2, "sand"); + simulation.BuildRgbaFrame(buffer); + + var sandIndex = ((2 * simulation.Width) + 2) * 4; + buffer[sandIndex].Should().Be(sand.Color.R); + + simulation.ClearParticleCircle(2, 2, 0); + simulation.BuildRgbaFrame(buffer); + + buffer[sandIndex].Should().Be(0); + buffer[sandIndex + 1].Should().Be(0); + buffer[sandIndex + 2].Should().Be(0); + buffer[sandIndex + 3].Should().Be(255); + } + + [Fact] + public void WindBrushWritesLocalWindField() + { + var simulation = CreateSimulation(); + + simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f); + + var wind = simulation.GetWindAtCell(4, 4); + wind.X.Should().BeGreaterThan(0f); + MathF.Abs(wind.Y).Should().BeLessThan(0.2f); + } + + [Fact] + public void AirBrushWritesPressureField() + { + var simulation = CreateSimulation(); + + simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f); + + simulation.GetAirPressureAtCell(4, 4).Should().BeGreaterThan(0f); + } + + [Fact] + public void FieldDecayContinuesWithoutParticles() + { + var simulation = CreateSimulation(); + simulation.Settings.PauseSim = false; + simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f); + var initialPressure = simulation.GetAirPressureAtCell(4, 4); + + simulation.Step(1f / 60f); + + simulation.GetAirPressureAtCell(4, 4).Should().BeLessThan(initialPressure); + simulation.ParticleCount.Should().Be(0); + } + + [Fact] + public void FreshlySpawnedFallingSandDoesNotImmediatelyCreateVisiblePressure() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 1, "sand"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + simulation.Step(1f / 60f); + + MathF.Abs(simulation.GetAirPressureAtCell(4, 3)).Should().BeLessThan(0.08f); + MathF.Abs(simulation.GetAirPressureAtCell(3, 3)).Should().BeLessThan(0.08f); + MathF.Abs(simulation.GetAirPressureAtCell(5, 3)).Should().BeLessThan(0.08f); + } + + [Fact] + public void GasRespondsMoreThanLiquidToSamePressureField() + { + var steamSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false }); + var waterSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false }); + + steamSimulation.CreateParticleAtPixel(6, 6, "steam"); + waterSimulation.CreateParticleAtPixel(6, 6, "water"); + + for (var i = 0; i < 6; i++) + { + steamSimulation.ApplyAirBrushAtPixel(3, 6, 3, 1f, 0f); + waterSimulation.ApplyAirBrushAtPixel(3, 6, 3, 1f, 0f); + steamSimulation.Step(1f / 60f); + waterSimulation.Step(1f / 60f); + } + + Math.Abs(FindParticleX(steamSimulation, "steam") - 6).Should().BeGreaterThan(0); + Math.Abs(FindParticleX(waterSimulation, "water") - 6).Should().BeLessThan(Math.Abs(FindParticleX(steamSimulation, "steam") - 6)); + } + + [Fact] + public void NewlySpawnedParticleClearsLocalForceAndPressureFields() + { + var simulation = CreateSimulation(); + + simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f); + simulation.ApplyAirBrushAtPixel(4, 4, 2, 1f, 0f); + simulation.GetAirPressureAtCell(4, 4).Should().BeGreaterThan(0f); + simulation.GetWindAtCell(4, 4).X.Should().BeGreaterThan(0f); + + simulation.CreateParticleAtPixel(4, 4, "sand"); + + simulation.GetAirPressureAtCell(4, 4).Should().Be(0f); + simulation.GetWindAtCell(4, 4).X.Should().Be(0f); + simulation.GetWindAtCell(4, 4).Y.Should().Be(0f); + simulation.GetForceAtCell(4, 4).X.Should().Be(0f); + simulation.GetForceAtCell(4, 4).Y.Should().Be(0f); + } + + [Fact] + public void PourPaintingAddsLimitedParticlesInsteadOfFillingEntireBrush() + { + var simulation = CreateSimulation(); + + simulation.CreateParticlePourAtPixel(8, 8, 3, "sand", 4, 1234); + + simulation.ParticleCount.Should().Be(4); + } + + [Fact] + public void FireTouchingWaterIsExtinguished() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(2, 2, "fire"); + simulation.CreateParticleAtPixel(3, 2, "water"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(2, 2).Should().Be(0); + var neighbor = simulation.GetTypeIdAtCell(3, 2); + neighbor.Should().BeOneOf((ushort)0, _library.GetTypeId("water"), _library.GetTypeId("steam")); + } + + [Fact] + public void FireRisesInsteadOfBehavingLikeSolid() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(5, 5, "fire"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("fire")); + } + + [Fact] + public void FireEventuallyProducesSmoke() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(5, 7, "fire"); + simulation.Settings.PauseSim = false; + + for (var i = 0; i < 12; i++) + { + simulation.Step(1f / 60f); + } + + CountParticles(simulation, "smoke").Should().BeGreaterThan(0); + } + + [Fact] + public void SandAndWaterDoNotSpontaneouslyGenerateSmokeOrEmbers() + { + var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(6, 6, "sand"); + simulation.CreateParticleAtPixel(9, 6, "water"); + + for (var i = 0; i < 20; i++) + { + simulation.Step(1f / 60f); + } + + CountParticles(simulation, "smoke").Should().Be(0); + CountParticles(simulation, "ember").Should().Be(0); + } + + [Fact] + public void BurningWoodStaysMostlyInPlaceAndProducesEffectParticles() + { + var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(8, 8, "burning_wood"); + + for (var i = 0; i < 24; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(8, 8).Should().Be(_library.GetTypeId("burning_wood")); + (CountParticles(simulation, "smoke") + CountParticles(simulation, "ember")).Should().BeGreaterThan(0); + } + + [Fact] + public void PlasmaTransfersEnergyToConductiveNeighbors() + { + var simulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(8, 8, "plasma"); + simulation.CreateParticleAtPixel(8, 7, "wall"); + simulation.CreateParticleAtPixel(7, 8, "iron"); + simulation.CreateParticleAtPixel(9, 8, "iron"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + simulation.CreateParticleAtPixel(9, 7, "wall"); + simulation.CreateParticleAtPixel(8, 9, "wall"); + + for (var i = 0; i < 12; i++) + { + simulation.Step(1f / 60f); + } + + (simulation.SparkTime[7, 8] != 0 || simulation.SparkTime[9, 8] != 0).Should().BeTrue(); + } + + [Fact] + public void SteamDoesNotImmediatelyCondenseAfterCreation() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(6, 6, "steam"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.CreateParticleAtPixel(7, 5, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(6, 7, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + + for (var i = 0; i < 6; i++) + { + simulation.Step(1f / 60f); + } + + CountParticles(simulation, "steam").Should().BeGreaterThan(0); + } + + [Fact] + public void CooledSteamCondensesBackIntoWaterInsteadOfDisappearing() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(6, 6, "steam"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.CreateParticleAtPixel(7, 5, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(6, 7, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + + for (var i = 0; i < 80; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("water")); + } + + [Fact] + public void SteamRisesFasterThanSmokeBecauseOfVelocity() + { + var steamSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false }); + var smokeSimulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false }); + steamSimulation.CreateParticleAtPixel(6, 9, "steam"); + smokeSimulation.CreateParticleAtPixel(6, 9, "smoke"); + + for (var i = 0; i < 8; i++) + { + steamSimulation.Step(1f / 60f); + smokeSimulation.Step(1f / 60f); + } + + FindParticleY(steamSimulation, "steam").Should().BeLessThan(FindParticleY(smokeSimulation, "smoke")); + } + + [Fact] + public void LavaDoesNotCoolImmediatelyAtAmbient() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(6, 6, "lava"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.CreateParticleAtPixel(7, 5, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(6, 7, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.Temperature[6, 6] = 820f; + + for (var i = 0; i < 8; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("lava")); + } + + [Fact] + public void LavaDoesNotSelfDestructFromHeatAlone() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(6, 6, "lava"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.CreateParticleAtPixel(7, 5, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(6, 7, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + + for (var i = 0; i < 45; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("lava")); + } + + [Fact] + public void MoltenGoldDoesNotSpreadFasterThanWaterInRuntimeTuning() + { + var waterSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + var moltenGoldSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + PrepareFloor(waterSimulation, 6); + PrepareFloor(moltenGoldSimulation, 6); + waterSimulation.CreateParticleAtPixel(8, 5, "water"); + moltenGoldSimulation.CreateParticleAtPixel(8, 5, "molten_gold"); + + for (var i = 0; i < 8; i++) + { + waterSimulation.Step(1f / 60f); + moltenGoldSimulation.Step(1f / 60f); + } + + var waterX = FindParticleX(waterSimulation, "water"); + var moltenGoldX = FindParticleX(moltenGoldSimulation, "molten_gold"); + Math.Abs(moltenGoldX - 8).Should().BeLessThanOrEqualTo(Math.Abs(waterX - 8)); + } + + [Fact] + public void LavaRespondsLessThanWaterToLateralForce() + { + var waterSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false }); + var lavaSimulation = new SandSimulation(16, 16, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 900f }); + PrepareFloor(waterSimulation, 6); + PrepareFloor(lavaSimulation, 6); + waterSimulation.CreateParticleAtPixel(8, 5, "water"); + lavaSimulation.CreateParticleAtPixel(8, 5, "lava"); + + for (var i = 0; i < 10; i++) + { + waterSimulation.ApplyWindBrushAtPixel(8, 5, 3, 1f, 0f); + lavaSimulation.ApplyWindBrushAtPixel(8, 5, 3, 1f, 0f); + waterSimulation.Step(1f / 60f); + lavaSimulation.Step(1f / 60f); + } + + var waterX = FindParticleX(waterSimulation, "water"); + var lavaX = FindParticleX(lavaSimulation, "lava"); + Math.Abs(lavaX - 8).Should().BeLessThan(Math.Abs(waterX - 8)); + } + + [Fact] + public void MoltenGoldDoesNotSelfDestructFromHeatAlone() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(6, 6, "molten_gold"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.CreateParticleAtPixel(7, 5, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(6, 7, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + + for (var i = 0; i < 35; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(6, 6).Should().Be(_library.GetTypeId("molten_gold")); + } + + [Fact] + public void IceAndSnowDoNotImmediatelyMeltAtThresholdTemperatures() + { + var simulation = new SandSimulation(12, 12, 1, _library, new SimulationSettings { PauseSim = false, AmbientTemperature = 22f }); + simulation.CreateParticleAtPixel(4, 6, "ice"); + simulation.CreateParticleAtPixel(8, 6, "snow"); + simulation.CreateParticleAtPixel(3, 6, "wall"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(3, 7, "wall"); + simulation.CreateParticleAtPixel(4, 7, "wall"); + simulation.CreateParticleAtPixel(5, 7, "wall"); + simulation.CreateParticleAtPixel(7, 6, "wall"); + simulation.CreateParticleAtPixel(9, 6, "wall"); + simulation.CreateParticleAtPixel(7, 7, "wall"); + simulation.CreateParticleAtPixel(8, 7, "wall"); + simulation.CreateParticleAtPixel(9, 7, "wall"); + simulation.Temperature[4, 6] = 8f; + simulation.Temperature[8, 6] = 10f; + + for (var i = 0; i < 6; i++) + { + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(4, 6).Should().Be(_library.GetTypeId("ice")); + simulation.GetTypeIdAtCell(8, 6).Should().Be(_library.GetTypeId("snow")); + } + + [Fact] + public void ExplosiveParticleDetonatesUnderSustainedPressure() + { + var simulation = new SandSimulation(32, 32, 1, _library, new SimulationSettings { PauseSim = false }); + simulation.CreateParticleAtPixel(16, 16, "ultratanium"); + + for (var i = 0; i < 8; i++) + { + simulation.ApplyAirBrushAtPixel(16, 16, 6, 1f, 0f); + simulation.Step(1f / 60f); + } + + simulation.GetTypeIdAtCell(16, 16).Should().Be(0); + (CountParticles(simulation, "smoke") + CountParticles(simulation, "fire")).Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public void AdjacentExplosivesDoNotRecursivelyOverflowWhenDetonated() + { + var simulation = new SandSimulation(32, 32, 1, _library, new SimulationSettings { PauseSim = false }); + for (var x = 12; x <= 18; x++) + { + for (var y = 12; y <= 18; y++) + { + simulation.CreateParticleAtPixel(x, y, "ultratanium"); + } + } + + for (var i = 0; i < 10; i++) + { + simulation.ApplyAirBrushAtPixel(15, 15, 8, 1f, 0f); + simulation.Step(1f / 60f); + } + + simulation.ParticleCount.Should().BeGreaterThanOrEqualTo(0); + } + + [Fact] + public void SustainedPressureBreaksStoneIntoBrokenStone() + { + var simulation = new SandSimulation(20, 20, 1, _library, new SimulationSettings { PauseSim = false }); + var brokenStone = _library.GetTypeId("brkstone"); + simulation.CreateParticleAtPixel(10, 10, "stone"); + simulation.CreateParticleAtPixel(9, 9, "wall"); + simulation.CreateParticleAtPixel(10, 9, "wall"); + simulation.CreateParticleAtPixel(11, 9, "wall"); + simulation.CreateParticleAtPixel(9, 10, "wall"); + simulation.CreateParticleAtPixel(11, 10, "wall"); + simulation.CreateParticleAtPixel(9, 11, "wall"); + simulation.CreateParticleAtPixel(10, 11, "wall"); + simulation.CreateParticleAtPixel(11, 11, "wall"); + + var broke = false; + for (var i = 0; i < 28; i++) + { + simulation.ApplyAirBrushAtPixel(10, 10, 6, 1f, 0f); + simulation.Step(1f / 60f); + if (simulation.GetTypeIdAtCell(10, 10) == brokenStone) + { + broke = true; + break; + } + } + + broke.Should().BeTrue(); + } + + [Fact] + public void WindBiasPushesGasSidewaysWhenUpwardPathIsBlocked() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 4, "steam"); + simulation.CreateParticleAtPixel(4, 3, "wall"); + simulation.CreateParticleAtPixel(3, 3, "wall"); + simulation.CreateParticleAtPixel(5, 3, "wall"); + simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f); + simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f); + simulation.ApplyWindBrushAtPixel(4, 4, 2, 1f, 0f); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("steam")); + } + + [Fact] + public void WindToolDoesNotPushWaterWhenMetadataExcludesIt() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 4, "water"); + simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f); + simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f); + simulation.ApplyWindBrushAtPixel(4, 4, 3, 1f, 0f); + + var wind = simulation.GetWindAtCell(4, 4); + MathF.Abs(wind.X).Should().BeLessThan(0.01f); + MathF.Abs(wind.Y).Should().BeLessThan(0.01f); + } + + [Fact] + public void GravityWellPullsGasSidewaysWhenUpwardPathIsBlocked() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(5, 4, "steam"); + simulation.CreateParticleAtPixel(5, 3, "wall"); + simulation.CreateParticleAtPixel(4, 3, "wall"); + simulation.CreateParticleAtPixel(6, 3, "wall"); + simulation.ApplyGravityBrushAtPixel(3, 4, 3, 6f); + simulation.ApplyGravityBrushAtPixel(3, 4, 3, 6f); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(4, 4).Should().Be(_library.GetTypeId("steam")); + } + + [Fact] + public void RepulsorPushesLiquidSidewaysWhenUpwardPathIsBlocked() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 4, "water"); + simulation.CreateParticleAtPixel(4, 5, "wall"); + simulation.CreateParticleAtPixel(3, 5, "wall"); + simulation.CreateParticleAtPixel(5, 5, "wall"); + simulation.ApplyRepulsorBrushAtPixel(3, 4, 3, 6f); + simulation.ApplyRepulsorBrushAtPixel(3, 4, 3, 6f); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(5, 4).Should().Be(_library.GetTypeId("water")); + } + + [Fact] + public void SolidDoesNotDiagonalSwapIntoLiquid() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(4, 4, "sand"); + simulation.CreateParticleAtPixel(4, 5, "wall"); + simulation.CreateParticleAtPixel(3, 5, "wall"); + simulation.CreateParticleAtPixel(5, 5, "water"); + simulation.CreateParticleAtPixel(5, 6, "wall"); + simulation.CreateParticleAtPixel(4, 6, "wall"); + simulation.CreateParticleAtPixel(6, 6, "wall"); + simulation.CreateParticleAtPixel(6, 5, "wall"); + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(4, 4).Should().Be(_library.GetTypeId("sand")); + simulation.GetTypeIdAtCell(5, 5).Should().Be(_library.GetTypeId("water")); + } + + [Fact] + public void MoltenLiquidCanSinkThroughLighterLiquidBelow() + { + var simulation = CreateSimulation(); + simulation.CreateParticleAtPixel(3, 3, "molten_gold"); + simulation.CreateParticleAtPixel(3, 4, "water"); + simulation.CreateParticleAtPixel(2, 3, "wall"); + simulation.CreateParticleAtPixel(4, 3, "wall"); + simulation.CreateParticleAtPixel(2, 4, "wall"); + simulation.CreateParticleAtPixel(4, 4, "wall"); + simulation.CreateParticleAtPixel(3, 5, "wall"); + simulation.CreateParticleAtPixel(2, 5, "wall"); + simulation.CreateParticleAtPixel(4, 5, "wall"); + simulation.Temperature[3, 3] = 2000f; + simulation.Settings.PauseSim = false; + + simulation.Step(1f / 60f); + + simulation.GetTypeIdAtCell(3, 4).Should().Be(_library.GetTypeId("molten_gold")); + simulation.GetTypeIdAtCell(3, 3).Should().Be(_library.GetTypeId("water")); + } + + private int CountParticles(SandSimulation simulation, string particleId) + { + var typeId = _library.GetTypeId(particleId); + var count = 0; + for (var y = 0; y < simulation.Height; y++) + { + for (var x = 0; x < simulation.Width; x++) + { + if (simulation.GetTypeIdAtCell(x, y) == typeId) + { + count++; + } + } + } + + return count; + } + + private int FindParticleX(SandSimulation simulation, string particleId) + { + var typeId = _library.GetTypeId(particleId); + for (var y = 0; y < simulation.Height; y++) + { + for (var x = 0; x < simulation.Width; x++) + { + if (simulation.GetTypeIdAtCell(x, y) == typeId) + { + return x; + } + } + } + + return -1; + } + + private int FindParticleY(SandSimulation simulation, string particleId) + { + var typeId = _library.GetTypeId(particleId); + for (var y = 0; y < simulation.Height; y++) + { + for (var x = 0; x < simulation.Width; x++) + { + if (simulation.GetTypeIdAtCell(x, y) == typeId) + { + return y; + } + } + } + + return int.MaxValue; + } + + private double FindAverageX(SandSimulation simulation, string particleId) + { + var typeId = _library.GetTypeId(particleId); + double total = 0; + var count = 0; + for (var y = 0; y < simulation.Height; y++) + { + for (var x = 0; x < simulation.Width; x++) + { + if (simulation.GetTypeIdAtCell(x, y) == typeId) + { + total += x; + count++; + } + } + } + + return count == 0 ? -1 : total / count; + } + + private void PrepareFloor(SandSimulation simulation, int y) + { + for (var x = 0; x < simulation.Width; x++) + { + simulation.CreateParticleAtPixel(x, y, "wall"); + } + } + + private SandSimulation CreateSimulation() => new(10, 10, 1, _library, new SimulationSettings { PauseSim = true }); +} diff --git a/Sand.sln b/Sand.sln new file mode 100644 index 0000000..1d2d855 --- /dev/null +++ b/Sand.sln @@ -0,0 +1,62 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.Core", "Sand.Core\Sand.Core.csproj", "{61ED9272-F50F-4B2A-865B-82C86C0C7284}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.App", "Sand.App\Sand.App.csproj", "{18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sand.Tests", "Sand.Tests\Sand.Tests.csproj", "{EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x64.ActiveCfg = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x64.Build.0 = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x86.ActiveCfg = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Debug|x86.Build.0 = Debug|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|Any CPU.Build.0 = Release|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x64.ActiveCfg = Release|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x64.Build.0 = Release|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x86.ActiveCfg = Release|Any CPU + {61ED9272-F50F-4B2A-865B-82C86C0C7284}.Release|x86.Build.0 = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x64.ActiveCfg = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x64.Build.0 = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x86.ActiveCfg = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Debug|x86.Build.0 = Debug|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|Any CPU.Build.0 = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x64.ActiveCfg = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x64.Build.0 = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x86.ActiveCfg = Release|Any CPU + {18EE7B34-DE1E-4CEF-AD2B-3B0F0D205206}.Release|x86.Build.0 = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x64.ActiveCfg = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x64.Build.0 = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x86.ActiveCfg = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Debug|x86.Build.0 = Debug|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|Any CPU.Build.0 = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x64.ActiveCfg = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x64.Build.0 = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x86.ActiveCfg = Release|Any CPU + {EBDF96F2-0BAD-46B8-9BE6-9A092F19B1A3}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/Sand.slnx b/Sand.slnx new file mode 100644 index 0000000..bd7a9bb --- /dev/null +++ b/Sand.slnx @@ -0,0 +1,5 @@ + + + + + diff --git a/advchksys/CHANGELOG.md b/advchksys/CHANGELOG.md new file mode 100644 index 0000000..a7b10c5 --- /dev/null +++ b/advchksys/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + + diff --git a/advchksys/README.md b/advchksys/README.md new file mode 100644 index 0000000..df1d46a --- /dev/null +++ b/advchksys/README.md @@ -0,0 +1,161 @@ +# AdvChkSys + +**AdvChkSys** is a high-performance, extensible chunked world management library for .NET, designed for games, simulations, and voxel engines. It provides efficient memory management, chunk loading/unloading, resource tracking, event hooks, serialization, and optional constraints for 2D and 3D chunk-based worlds. + +--- + +## Features + +- **2D and 3D Chunk Management** + Efficient, thread-safe managers for 2D and 3D chunks with support for custom data types. + +- **Memory Efficiency** + LRU caching, array pooling, and all-air chunk singletons minimize memory usage and maximize performance. + +- **Resource Tracking** + Built-in resource manager tracks allocated chunks and supports diagnostics and pooling. + +- **Event System** + Thread-safe events for chunk lifecycle: loading, unloading, saving, and more. + +- **World Constraints** + Optional constraints for world size and loaded chunk limits. + +- **Serialization** + Fast binary serialization/deserialization for chunk data. + +- **Async Utilities** + Helpers for running chunk tasks asynchronously. + +- **Python/.NET Interop** + Python bindings for scripting and integration. + +--- + +## Getting Started + +### Requirements + +- .NET Standard 2.1 or later + +### Building + +You can build the project using the included build script or directly with dotnet CLI: + +#### Using build.bat (Windows) + +```bash +build.bat [options] +``` + +Available options: + +- `--all`: Build for all platforms, including benchmarks and NuGet package +- `--windows`: Build for Windows (default) +- `--linux`: Build for Linux +- `--mac`: Build for macOS +- `--benchmarks`: Build the ChunkMark benchmarking tool +- `--nuget`: Create a NuGet package +- `--docs`: Generate documentation +- `--debug`: Build in Debug configuration (Release is default) + +Examples: + +```bash +# Build for all platforms +build.bat --all + +# Build for Windows and Linux +build.bat --windows --linux + +# Build benchmarks in debug mode +build.bat --benchmarks --debug +``` + +#### Using dotnet CLI + +```bash +dotnet build src/AdvChkSys/AdvChkSys.csproj +``` + +### Basic Usage + +```csharp +using AdvChkSys; +using AdvChkSys.Manager; + +// Create a 2D chunk manager +var manager2D = AdvChkSys.AdvChkSys.Create2DManager(); + +// Create or load a chunk +var chunk = manager2D.LoadOrCreateChunk(0, 0, 32, 32); + +// Set a cell value +chunk[0, 0] = 42; + +// Unload a chunk +manager2D.UnloadChunk(0, 0); +``` + +--- + +## Python Bindings + +Python bindings are available in `src/bindings/python/`. +See [`src/bindings/python/README.md`](src/bindings/python/README.md) for usage. + +--- + +## Documentation + +- API documentation can be generated with [DocFX](https://dotnet.github.io/docfx/). +- XML documentation is included in the build. + +--- + +## License + +This project is licensed under the MIT License. See [LICENSE](LICENSE) for details. + +--- + +## Acknowledgments + +- Inspired by voxel engines and chunked world systems such as Minecraft. +- Uses [DocFX](https://dotnet.github.io/docfx/) for documentation generation. + +--- + +## Contributing + +Pull requests and issues are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) if available, or open an issue to discuss your ideas. + +--- + +## System Requirements + +- Windows, Linux, or macOS operating system +- .NET 5.0 or higher +- Sufficient memory for the requested benchmark parameters (e.g., 3D benchmark with 1024 chunks of size 384×384×384 would require approximately 54-60 GB of RAM) (e.g., 3D benchmark with 1000 chunks of size 32×32×32 would require approximately 125 MB of RAM) +- Math for 1024 chunks at a size of 384x384x384. + +### Memory Calculation + +For 1024 chunks at a size of 384×384×384: + +```python +###Py used for COLORING### +# Calculate cells per chunk +cells_per_chunk = 384 × 384 × 384 = 56,623,104 cells + +# Calculate total bytes +total_bytes = cells_per_chunk × 1024 chunks = 57,972,964,416 bytes + +# Convert to GB +total_GB = total_bytes / 1024 / 1024 / 1024 = 54.2 GB + +# Add 5-20% overhead for metadata, references, and system overhead +final_estimate = 54.2 GB × (1.05 to 1.20) = 56.9 to 65.0 GB +``` + +--- \ No newline at end of file diff --git a/advchksys/build.bat b/advchksys/build.bat new file mode 100644 index 0000000..ad0d58d --- /dev/null +++ b/advchksys/build.bat @@ -0,0 +1,237 @@ +@echo off +setlocal enabledelayedexpansion + +:: AdvChkSys Build Script +:: ====================== + +:: Set build parameters +set "PROJECT_DIR=src\AdvChkSys" +set "BENCHMARKS_DIR=src\AdvChkSys.Benchmarks" +set "OUTPUT_DIR=build" +set "VERSION=0.1.8" + +:: Create build directory if it doesn't exist +if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%" + +:: Display header +echo ╔══════════════════════════════════════════════════════════════╗ +echo ║ ADVANCED CHUNK SYSTEM BUILD ║ +echo ╚══════════════════════════════════════════════════════════════╝ +echo. +echo Version: %VERSION% +echo Date: %DATE% %TIME% +echo. + +:: Check for .NET SDK +echo Checking for .NET SDK... +dotnet --version > nul 2>&1 +if %ERRORLEVEL% neq 0 ( + echo Error: .NET SDK not found. Please install the .NET SDK. + exit /b 1 +) +echo ✓ .NET SDK found +echo. + +:: Parse command line arguments +set "BUILD_WINDOWS=true" +set "BUILD_LINUX=false" +set "BUILD_MAC=false" +set "BUILD_BENCHMARKS=false" +set "BUILD_NUGET=false" +set "BUILD_DOCS=false" +set "CONFIG=Release" + +:parse_args +if "%~1"=="" goto end_parse_args +if /i "%~1"=="--all" ( + set "BUILD_WINDOWS=true" + set "BUILD_LINUX=true" + set "BUILD_MAC=true" + set "BUILD_BENCHMARKS=true" + set "BUILD_NUGET=true" + set "BUILD_DOCS=true" +) else if /i "%~1"=="--windows" ( + set "BUILD_WINDOWS=true" +) else if /i "%~1"=="--linux" ( + set "BUILD_LINUX=true" +) else if /i "%~1"=="--mac" ( + set "BUILD_MAC=true" +) else if /i "%~1"=="--benchmarks" ( + set "BUILD_BENCHMARKS=true" +) else if /i "%~1"=="--nuget" ( + set "BUILD_NUGET=true" +) else if /i "%~1"=="--docs" ( + set "BUILD_DOCS=true" +) else if /i "%~1"=="--debug" ( + set "CONFIG=Debug" +) +shift +goto parse_args +:end_parse_args + +:: Display build configuration +echo Build Configuration: +echo • Configuration: %CONFIG% +echo • Windows: %BUILD_WINDOWS% +echo • Linux: %BUILD_LINUX% +echo • macOS: %BUILD_MAC% +echo • Benchmarks: %BUILD_BENCHMARKS% +echo • NuGet Package: %BUILD_NUGET% +echo • Documentation: %BUILD_DOCS% +echo. + +:: Clean previous builds +echo Cleaning previous builds... +if exist "%PROJECT_DIR%\bin" rd /s /q "%PROJECT_DIR%\bin" +if exist "%PROJECT_DIR%\obj" rd /s /q "%PROJECT_DIR%\obj" +if exist "%BENCHMARKS_DIR%\bin" rd /s /q "%BENCHMARKS_DIR%\bin" +if exist "%BENCHMARKS_DIR%\obj" rd /s /q "%BENCHMARKS_DIR%\obj" +echo ✓ Clean completed +echo. + +:: Restore packages +echo Restoring packages... +dotnet restore "%PROJECT_DIR%\AdvChkSys.csproj" +if %ERRORLEVEL% neq 0 ( + echo ✗ Package restore failed + exit /b 1 +) +echo ✓ Packages restored +echo. + +:: Build the library (platform-agnostic) +echo Building AdvChkSys library... +dotnet build "%PROJECT_DIR%\AdvChkSys.csproj" -c %CONFIG% --no-restore +if %ERRORLEVEL% neq 0 ( + echo ✗ Library build failed + exit /b 1 +) + +:: Copy library to output directories +if not exist "%OUTPUT_DIR%\lib" mkdir "%OUTPUT_DIR%\lib" +copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\lib\" +copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\lib\" + +:: Create platform-specific directories +if "%BUILD_WINDOWS%"=="true" ( + if not exist "%OUTPUT_DIR%\windows" mkdir "%OUTPUT_DIR%\windows" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\windows\" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\windows\" + echo ✓ Windows build completed +) + +if "%BUILD_LINUX%"=="true" ( + if not exist "%OUTPUT_DIR%\linux" mkdir "%OUTPUT_DIR%\linux" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\linux\" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\linux\" + echo ✓ Linux build completed +) + +if "%BUILD_MAC%"=="true" ( + if not exist "%OUTPUT_DIR%\macos" mkdir "%OUTPUT_DIR%\macos" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.dll" "%OUTPUT_DIR%\macos\" + copy "%PROJECT_DIR%\bin\%CONFIG%\netstandard2.1\AdvChkSys.xml" "%OUTPUT_DIR%\macos\" + echo ✓ macOS build completed +) +echo. + +:: Build benchmarks +if "%BUILD_BENCHMARKS%"=="true" ( + echo Building ChunkMark benchmarks... + + :: Restore benchmark packages + dotnet restore "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" + + :: Build for Windows + if "%BUILD_WINDOWS%"=="true" ( + dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r win-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + if %ERRORLEVEL% neq 0 ( + echo ✗ Windows benchmark build failed + ) else ( + if not exist "%OUTPUT_DIR%\benchmarks\windows" mkdir "%OUTPUT_DIR%\benchmarks\windows" + copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\win-x64\publish\ChunkMark.exe" "%OUTPUT_DIR%\benchmarks\windows\" + echo ✓ Windows benchmarks built + ) + ) + + :: Build for Linux + if "%BUILD_LINUX%"=="true" ( + dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + if %ERRORLEVEL% neq 0 ( + echo ✗ Linux benchmark build failed + ) else ( + if not exist "%OUTPUT_DIR%\benchmarks\linux" mkdir "%OUTPUT_DIR%\benchmarks\linux" + copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\linux-x64\publish\ChunkMark" "%OUTPUT_DIR%\benchmarks\linux\" + echo ✓ Linux benchmarks built + ) + ) + + :: Build for macOS + if "%BUILD_MAC%"=="true" ( + dotnet publish "%BENCHMARKS_DIR%\AdvChkSys.Benchmarks.csproj" -c %CONFIG% -r osx-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + if %ERRORLEVEL% neq 0 ( + echo ✗ macOS benchmark build failed + ) else ( + if not exist "%OUTPUT_DIR%\benchmarks\macos" mkdir "%OUTPUT_DIR%\benchmarks\macos" + copy "%BENCHMARKS_DIR%\bin\%CONFIG%\net6.0\osx-x64\publish\ChunkMark" "%OUTPUT_DIR%\benchmarks\macos\" + echo ✓ macOS benchmarks built + ) + ) + echo. +) + +:: Build NuGet package +if "%BUILD_NUGET%"=="true" ( + echo Building NuGet package... + dotnet pack "%PROJECT_DIR%\AdvChkSys.csproj" -c %CONFIG% --no-build --output "%OUTPUT_DIR%\nuget" + if %ERRORLEVEL% neq 0 ( + echo ✗ NuGet package creation failed + exit /b 1 + ) + echo ✓ NuGet package created + echo. +) + +:: Build documentation +if "%BUILD_DOCS%"=="true" ( + echo Building documentation... + echo Documentation generation not implemented yet. + echo. +) + +:: Display summary +echo ╔══════════════════════════════════════════════════════════════╗ +echo ║ BUILD SUMMARY ║ +echo ╚══════════════════════════════════════════════════════════════╝ +echo. +echo Build completed successfully! +echo. +echo Output files: +echo • Library: %OUTPUT_DIR%\lib\AdvChkSys.dll + +if "%BUILD_WINDOWS%"=="true" ( + echo • Windows: %OUTPUT_DIR%\windows\AdvChkSys.dll + if "%BUILD_BENCHMARKS%"=="true" ( + echo • Windows Benchmark: %OUTPUT_DIR%\benchmarks\windows\ChunkMark.exe + ) +) +if "%BUILD_LINUX%"=="true" ( + echo • Linux: %OUTPUT_DIR%\linux\AdvChkSys.dll + if "%BUILD_BENCHMARKS%"=="true" ( + echo • Linux Benchmark: %OUTPUT_DIR%\benchmarks\linux\ChunkMark + ) +) +if "%BUILD_MAC%"=="true" ( + echo • macOS: %OUTPUT_DIR%\macos\AdvChkSys.dll + if "%BUILD_BENCHMARKS%"=="true" ( + echo • macOS Benchmark: %OUTPUT_DIR%\benchmarks\macos\ChunkMark + ) +) +if "%BUILD_NUGET%"=="true" ( + echo • NuGet: %OUTPUT_DIR%\nuget\AdvChkSys.%VERSION%.nupkg +) +echo. +echo Run the build script with --help for more options. +echo. + +endlocal \ No newline at end of file diff --git a/advchksys/scripts/track_progress/CHANGELOG.md b/advchksys/scripts/track_progress/CHANGELOG.md new file mode 100644 index 0000000..4dc68c6 --- /dev/null +++ b/advchksys/scripts/track_progress/CHANGELOG.md @@ -0,0 +1,2 @@ +# Changelog + diff --git a/advchksys/scripts/track_progress/README.md b/advchksys/scripts/track_progress/README.md new file mode 100644 index 0000000..2733efc --- /dev/null +++ b/advchksys/scripts/track_progress/README.md @@ -0,0 +1,207 @@ +# Progress Tracker + +A comprehensive tool for tracking project progress, feature status, and generating documentation from Git history. + +## Overview + +The Progress Tracker analyzes your Git repository and codebase to automatically generate: + +- Feature tracking documentation +- Project status reports +- Changelog based on commit messages +- Code statistics and metrics + +## Installation + +### Prerequisites + +- Python 3.8 or higher +- Git + +### Dependencies + +Install the required dependencies: + +```bash +pip install -r scripts/track_progress/requirements.txt +``` + +For development or building the executable, install all dependencies: + +```bash +pip install -r scripts/track_progress/requirements.txt[all] +``` + +### Quick Install + +Run the setup script to install the tracker to your project: + +```bash +python scripts/track_progress/scripts/setup_install.py --project "Your Project Name" --dest path/to/destination +``` + +Options: +- `--project name`: Set your project name +- `--dest folder`: Set destination directory (default: "scripts") +- `--verbose` or `-v`: Enable verbose output +- `--force` or `-f`: Force overwrite existing files + +### Manual Installation + +1. Copy the `track_progress` directory to your project +2. Create a `docs/status` directory in your project root +3. Create a `docs/features.md` file if it doesn't exist +4. Create a `CHANGELOG.md` file in your project root + +## Usage + +### Basic Usage + +Run the tracker with default settings: + +```bash +# Using the Python script +python scripts/track_progress/track_progress.py + +# Using the executable (if built) +scripts/track_progress/track_progress +``` + +### Command-Line Options + +The tracker supports the following command-line options: + +``` +--config PATH Path to configuration file (default: scripts/track_progress/progress_config.json) +--project NAME Project name (overrides config) +--output-dir DIR Output directory for status document (overrides config) +--repo PATH Repository path to analyze (default: current directory) +--verbose, -v Enable verbose output +``` + +Examples: + +```bash +# Specify a custom configuration file +python scripts/track_progress/track_progress.py --config path/to/config.json + +# Override project name +python scripts/track_progress/track_progress.py --project "My Project" + +# Specify output directory +python scripts/track_progress/track_progress.py --output-dir docs/my-status + +# Analyze a different repository +python scripts/track_progress/track_progress.py --repo path/to/repo + +# Enable verbose output +python scripts/track_progress/track_progress.py --verbose +``` + +## Commit Message Tags + +The tracker recognizes special tags in commit messages to update feature status and documentation: + +| Tag | Description | Example | +|-----|-------------|---------| +| `[status:key]` | Update status of a component | `[status:database]` | +| `[feature:key]` | Mark a feature as completed | `[feature:login]` | +| `[new-feature:key:description]` | Add a new feature | `[new-feature:search:Implement search functionality]` | +| `[changelog:message]` | Add an entry to the changelog | `[changelog:Added dark mode]` | +| `[fix:description]` | Document a bug fix | `[fix:Fixed login issue]` | +| `[issue:id]` | Reference an issue | `[issue:42]` | +| `[breaking]` | Mark a breaking change | `[breaking]` | +| `[roadmap:milestone:item]` | Add an item to the roadmap | `[roadmap:v2:Add OAuth support]` | +| `[milestone:key]` | Reference a milestone | `[milestone:v1.0]` | +| `[priority:level]` | Set priority level | `[priority:high]` | +| `[owner:name]` | Assign an owner | `[owner:John]` | +| `[tag:name]` | Add a custom tag | `[tag:security]` | + +Example commit message: +``` +Implement search functionality [feature:search] [changelog:Added search with filtering] +``` + +## Configuration + +The tracker uses a JSON configuration file (`progress_config.json`) with the following settings: + +```json +{ + "project_name": "Your Project", + "output_dir": "docs/status", + "status_doc": "status.md", + "features_file": "docs/features.md", + "changelog_file": "CHANGELOG.md", + "git_log_limit": 100, + "untagged_commit_limit": 50, + "top_files_limit": 20, + "exclude_dirs": ["node_modules", "venv", ".git"], + "source_extensions": [".py", ".cs", ".js"], + "templates": { + "status": "status_template.md", + "features": "feature_template.md", + "changelog": "changelog_template.md" + } +} +``` + +## Output Files + +The tracker generates the following files: + +- `docs/features.md`: Feature tracking document +- `docs/status/status.md`: Project status document (or path specified in config) +- `CHANGELOG.md`: Project changelog + +## Components + +The tracker consists of the following components: + +- `track_progress.py`: Main script that orchestrates the tracking process +- `config.py`: Configuration management +- `git_analyzer.py`: Git history analysis +- `code_stats.py`: Code statistics and metrics +- `feature_markdown.py`: Feature tracking utilities +- `template_engine.py`: Template rendering engine +- `changelog_generator.py`: Changelog generation +- `roadmap_generator.py`: Roadmap generation + +## Building an Executable + +You can build a standalone executable using Nuitka: + +```bash +python scripts/track_progress/scripts/build_with_nuitka.py +``` + +This creates an executable in the `scripts/track_progress/dist` directory. + +## Customizing Templates + +The tracker uses Markdown templates to generate documentation. You can customize these templates in the `scripts/track_progress/templates` directory: + +- `status_template.md`: Template for the status document +- `feature_template.md`: Template for the feature tracking document +- `changelog_template.md`: Template for the changelog + +The templates use a simple syntax: +- `{{ variable }}`: Insert a variable +- `{% if condition %}...{% endif %}`: Conditional block +- `{% for item in items %}...{% endfor %}`: Loop over items + +## Troubleshooting + +### Common Issues + +1. **Missing Git Repository**: Ensure you're running the tracker in a Git repository. +2. **No Features Found**: Create a basic `docs/features.md` file with at least one feature. +3. **Template Not Found**: Check that templates exist in the templates directory. + +### Debug Mode + +Run with `--verbose` to see detailed output: + +```bash +python scripts/track_progress/track_progress.py --verbose +``` diff --git a/advchksys/scripts/track_progress/__init__.py b/advchksys/scripts/track_progress/__init__.py new file mode 100644 index 0000000..c2d4cd1 --- /dev/null +++ b/advchksys/scripts/track_progress/__init__.py @@ -0,0 +1 @@ +# This file makes the directory a Python package \ No newline at end of file diff --git a/advchksys/scripts/track_progress/changelog_generator.py b/advchksys/scripts/track_progress/changelog_generator.py new file mode 100644 index 0000000..7c012b3 --- /dev/null +++ b/advchksys/scripts/track_progress/changelog_generator.py @@ -0,0 +1,103 @@ +""" +Changelog generator for project documentation. +""" + +import os +from datetime import datetime +from typing import List, Dict, Any + + +def update_changelog_file( + changelog_entries: List[Dict[str, Any]], + changelog_file: str, + max_entries: int = 100, + verbose: bool = False, +) -> bool: + """Update the changelog file with new entries.""" + if not changelog_file: + if verbose: + print("No changelog file specified, skipping update") + return False + + if verbose: + print(f"Updating changelog file: {changelog_file}") + print(f"Found {len(changelog_entries)} entries to add") + + # Ensure the directory exists if the file is not in the current directory + changelog_dir = os.path.dirname(changelog_file) + if ( + changelog_dir + ): # Only create directory if there is one (not empty string) + os.makedirs(changelog_dir, exist_ok=True) + + # Create the file if it doesn't exist + if not os.path.exists(changelog_file): + with open(changelog_file, "w", encoding="utf-8") as f: + f.write("# Changelog\n\n") + if verbose: + print(f"Created new changelog file: {changelog_file}") + + # Read existing content + with open(changelog_file, "r", encoding="utf-8") as f: + content = f.read() + + # Extract header (everything before the first entry) + header_end = content.find("## ") + if header_end == -1: + header = "# Changelog\n\n" + else: + header = content[:header_end] + + # Format new entries + new_entries = [] + + # Group entries by date + entries_by_date = {} + for entry in changelog_entries: + date = entry.get("date", "") + if not date: + continue + + if date not in entries_by_date: + entries_by_date[date] = [] + + entries_by_date[date].append(entry) + + # Sort dates in reverse chronological order + for date in sorted(entries_by_date.keys(), reverse=True): + entries = entries_by_date[date] + + # Format date as a section header + new_entries.append(f"## {date}\n") + + # Add each entry + for entry in entries: + message = entry.get("message", "").strip() + commit = entry.get("commit", "") + + if message: + # Clean up the message + if message.startswith("- "): + message = message[2:] + + new_entries.append(f"- {message} ({commit})") + + new_entries.append("") # Add an empty line after each date section + + # If no new entries, just return + if not new_entries: + if verbose: + print("No new entries to add to changelog") + return False + + # Combine header and new entries + new_content = header + "\n".join(new_entries) + + # Write the updated content + with open(changelog_file, "w", encoding="utf-8") as f: + f.write(new_content) + + if verbose: + print(f"Updated changelog with {len(changelog_entries)} new entries") + + return True diff --git a/advchksys/scripts/track_progress/code_stats.py b/advchksys/scripts/track_progress/code_stats.py new file mode 100644 index 0000000..1fc03da --- /dev/null +++ b/advchksys/scripts/track_progress/code_stats.py @@ -0,0 +1,121 @@ +import os +import glob +from typing import Dict, Any, List, Optional + + +def count_lines(file_path: str) -> int: + """Count lines in a file.""" + try: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: + return len(f.readlines()) + except Exception: + return 0 + + +def get_language(file_path: str) -> str: + """Determine language from file extension.""" + ext = os.path.splitext(file_path)[1].lower() + language_map = { + ".py": "Python", + ".cs": "C#", + ".js": "JavaScript", + ".ts": "TypeScript", + ".html": "HTML", + ".css": "CSS", + ".md": "Markdown", + ".json": "JSON", + ".xml": "XML", + ".java": "Java", + ".cpp": "C++", + ".c": "C", + ".h": "C/C++ Header", + ".go": "Go", + ".rs": "Rust", + ".php": "PHP", + ".rb": "Ruby", + ".sh": "Shell", + ".bat": "Batch", + ".ps1": "PowerShell", + ".sql": "SQL", + ".yaml": "YAML", + ".yml": "YAML", + } + return language_map.get(ext, "Other") + + +def analyze_code_stats( + root_dir: str = ".", + exclude_dirs: List[str] = None, + source_extensions: List[str] = None, + top_files_limit: int = 20, +) -> Dict[str, Any]: + """Analyze code statistics for the project.""" + if exclude_dirs is None: + exclude_dirs = ["node_modules", "venv", ".git", ".vs", "bin", "obj"] + + if source_extensions is None: + source_extensions = [ + ".py", + ".cs", + ".js", + ".ts", + ".html", + ".css", + ".md", + ] + + stats = { + "total_lines": 0, + "file_count": 0, + "languages": {}, + "top_files": [], + } + + file_stats = [] + + # Convert exclude_dirs to absolute paths + exclude_paths = [os.path.join(root_dir, d) for d in exclude_dirs] + + # Find all source files + for ext in source_extensions: + pattern = os.path.join(root_dir, "**", f"*{ext}") + for file_path in glob.glob(pattern, recursive=True): + # Skip excluded directories + if any( + file_path.startswith(exclude_path) + for exclude_path in exclude_paths + ): + continue + + # Count lines + lines = count_lines(file_path) + + # Get language + language = get_language(file_path) + + # Update statistics + stats["total_lines"] += lines + stats["file_count"] += 1 + + # Update language count + if language not in stats["languages"]: + stats["languages"][language] = 0 + stats["languages"][language] += 1 + + # Add to file stats + file_stats.append( + { + "path": file_path, + "name": os.path.basename(file_path), + "lines": lines, + "language": language, + } + ) + + # Sort file stats by line count + file_stats.sort(key=lambda x: x["lines"], reverse=True) + + # Get top files + stats["top_files"] = file_stats[:top_files_limit] + + return stats diff --git a/advchksys/scripts/track_progress/config.py b/advchksys/scripts/track_progress/config.py new file mode 100644 index 0000000..bec408d --- /dev/null +++ b/advchksys/scripts/track_progress/config.py @@ -0,0 +1,87 @@ +""" +Configuration management for project status documentation. +""" + +import os +import json +from typing import Dict, Any, List, Optional + + +class Config: + """Configuration manager for project status documentation.""" + + def __init__(self, config_file: str = "scripts/progress_config.json"): + """Initialize with configuration file.""" + self.config_file = config_file + self.config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load configuration from file.""" + default_config = { + "project_name": "Project", + "output_dir": "docs/status", + "status_doc": "status.md", + "features_file": "docs/features.md", + "changelog_file": "CHANGELOG.md", + "git_log_limit": 100, + "untagged_commit_limit": 50, + "top_files_limit": 20, + "exclude_dirs": [ + "node_modules", + "venv", + ".git", + ".vs", + "bin", + "obj", + ], + "source_extensions": [".py", ".cs", ".js", ".ts", ".html", ".css"], + "templates": { + "status": "status_template.md", + "features": "feature_template.md", + "changelog": "changelog_template.md", + }, + } + + if not os.path.exists(self.config_file): + return default_config + + try: + with open(self.config_file, "r", encoding="utf-8") as f: + config = json.load(f) + + # Merge with default config to ensure all keys exist + merged_config = default_config.copy() + merged_config.update(config) + + return merged_config + except Exception: + return default_config + + def get(self, key: str, default: Any = None) -> Any: + """Get a configuration value.""" + if "." in key: + # Handle nested keys like "templates.status" + parts = key.split(".") + value = self.config + for part in parts: + if isinstance(value, dict) and part in value: + value = value[part] + else: + return default + return value + + return self.config.get(key, default) + + def set(self, key: str, value: Any) -> None: + """Set a configuration value.""" + if "." in key: + # Handle nested keys + parts = key.split(".") + config = self.config + for part in parts[:-1]: + if part not in config: + config[part] = {} + config = config[part] + config[parts[-1]] = value + else: + self.config[key] = value diff --git a/advchksys/scripts/track_progress/feature_markdown.py b/advchksys/scripts/track_progress/feature_markdown.py new file mode 100644 index 0000000..4b74fda --- /dev/null +++ b/advchksys/scripts/track_progress/feature_markdown.py @@ -0,0 +1,317 @@ +""" +Feature markdown utilities for project status documentation. +""" + +import os +from typing import Dict, Any, List, Optional +import re +from datetime import datetime + +# Mapping of status to emojis +STATUS_EMOJIS = { + "completed": "✅", + "in_progress": "🔄", + "planned": "📝", + "error": "☣️", + "blocked": "⛔", + "failed": "❌", + "warning": "⚠️", + "unknown": "❓", + "done": "✅", + "testing": "🧪", + "review": "👀", + "design": "🎨", + "research": "🔍", + "deprecated": "🗑️", + "postponed": "⏳", +} + + +def emoji_status(status: str) -> str: + """Return emoji for a status.""" + return STATUS_EMOJIS.get(status.lower(), STATUS_EMOJIS["unknown"]) + + +def format_feature_md(feature_name: str, data: dict) -> str: + """Format a single feature into Markdown.""" + status = data.get("status", "unknown") + status_emoji = emoji_status(status) + + lines = [f"## {feature_name}"] + lines.append(f"- Status: {status_emoji} {status}") + + if "description" in data: + lines.append(f"- Description: {data['description']}") + + if "date" in data: + lines.append(f"- Last Update: {data['date']}") + + if "author" in data: + lines.append(f"- Updated By: {data['author']}") + + if "details" in data: + lines.append(f"- Details: {data['details']}") + + return "\n".join(lines) + + +def build_feature_md(features: dict, title: str = "# Feature Tracking") -> str: + """Build the full Markdown document from features dictionary.""" + lines = [title, ""] + + for feature_name, data in features.items(): + lines.append(format_feature_md(feature_name, data)) + lines.append("") # Add an empty line between features + + return "\n".join(lines) + + +def extract_features( + features_file: str, verbose: bool = False +) -> Dict[str, Dict[str, Any]]: + """Extract all valid features from the file, focusing on descriptions.""" + features = {} + seen_descriptions = set() + + if not os.path.exists(features_file): + if verbose: + print(f"File not found: {features_file}") + return features + + with open(features_file, "r", encoding="utf-8") as f: + content = f.read() + + # First, try to extract features from the specific format + # This format has ### feature_name followed by status, description, date + status_blocks = re.findall( + r"### ([^\n]+)\s+\*\*Status:\*\* ([^\n]+)\s+\*\*Description:\*\* ([^\n]+)\s+\*\*Last Update:\*\* ([^\n]+)", + content, + ) + + for feature_key, status_line, desc, date in status_blocks: + desc = desc.strip() + date = date.strip() + + if desc and len(desc) > 2 and desc not in seen_descriptions: + seen_descriptions.add(desc) + + # Extract status without emoji + status_match = re.search( + r"[^a-zA-Z]*([a-zA-Z_]+)", status_line + ) + status = ( + status_match.group(1).lower() + if status_match + else "planned" + ) + + # Clean up the feature key + clean_key = re.sub( + r"[^a-zA-Z0-9_]", "", feature_key.replace(" ", "_").lower() + ) + if not clean_key or len(clean_key) < 2: + # Generate key from description + words = desc.split()[:3] + clean_key = "_".join(words).lower() + clean_key = re.sub(r"[^a-zA-Z0-9_]", "", clean_key) + + features[clean_key] = { + "status": status, + "description": desc, + "date": date, + "author": "", + "details": "", + } + + # Also try the format: ### **Status:** status \n **Description:** desc \n **Last Update:** date + alt_blocks = re.findall( + r"### \*\*Status:\*\* ([^\n]+)\s+\*\*Description:\*\* ([^\n]+)\s+\*\*Last Update:\*\* ([^\n]+)", + content, + ) + + for status_line, desc, date in alt_blocks: + desc = desc.strip() + date = date.strip() + + if desc and len(desc) > 2 and desc not in seen_descriptions: + seen_descriptions.add(desc) + + # Extract status without emoji + status_match = re.search( + r"[^a-zA-Z]*([a-zA-Z_]+)", status_line + ) + status = ( + status_match.group(1).lower() + if status_match + else "planned" + ) + + # Generate key from description + words = desc.split()[:3] + clean_key = "_".join(words).lower() + clean_key = re.sub(r"[^a-zA-Z0-9_]", "", clean_key) + + features[clean_key] = { + "status": status, + "description": desc, + "date": date, + "author": "", + "details": "", + } + + return features + + +def enrich_features( + features: Dict[str, Dict[str, Any]] +) -> Dict[str, Dict[str, Any]]: + """Add additional information to features for display purposes.""" + enriched = {} + + for key, data in features.items(): + enriched[key] = data.copy() + status = data.get("status", "unknown") + enriched[key]["status_emoji"] = emoji_status(status) + + # Add title-cased key for display + enriched[key]["display_name"] = key.replace("_", " ").title() + + return enriched + + +def generate_features_md( + features: Dict[str, Dict[str, Any]], project_name: str +) -> str: + """Generate a clean features markdown from the features dictionary.""" + lines = [ + "# Feature Tracking", + "", + "## Overview", + "", + f"This document tracks the implementation status of all features in the {project_name} project.", + "", + "## Feature Status Summary", + "", + "| Status | Description |", + "|--------|-------------|", + "| ✅ Completed | Features that are fully implemented and tested |", + "| 🔄 In Progress | Features currently being implemented |", + "| 📝 Planned | Features planned for future implementation |", + "", + "## Features", + "", + ] + + # Add each feature + for feature_key, feature in sorted(features.items()): + status = feature.get("status", "planned").lower() + status_emoji = emoji_status(status) + description = feature.get("description", "") + date = feature.get("date", "") or datetime.now().strftime("%Y-%m-%d") + author = feature.get("author", "") + details = feature.get("details", "") + + lines.append(f"### {feature_key}") + lines.append("") + lines.append(f"**Status:** {status_emoji} {status}") + lines.append(f"**Description:** {description}") + lines.append(f"**Last Update:** {date}") + + if author: + lines.append(f"**Owner:** {author}") + + if details: + lines.append(f"**Details:** {details}") + + lines.append("") + + # Add categorized lists + lines.append("## Feature Categories") + lines.append("") + + # Completed features + lines.append("### Completed Features") + lines.append("") + completed_count = 0 + for feature_key, feature in sorted(features.items()): + if feature.get("status", "").lower() in ["completed", "done"]: + lines.append( + f"- **{feature_key}**: {feature.get('description', '')}" + ) + completed_count += 1 + if completed_count == 0: + lines.append("*No completed features yet.*") + lines.append("") + + # In progress features + lines.append("### In Progress Features") + lines.append("") + in_progress_count = 0 + for feature_key, feature in sorted(features.items()): + if feature.get("status", "").lower() == "in_progress": + lines.append( + f"- **{feature_key}**: {feature.get('description', '')}" + ) + in_progress_count += 1 + if in_progress_count == 0: + lines.append("*No features currently in progress.*") + lines.append("") + + # Planned features + lines.append("### Planned Features") + lines.append("") + planned_count = 0 + for feature_key, feature in sorted(features.items()): + if feature.get("status", "").lower() == "planned": + lines.append( + f"- **{feature_key}**: {feature.get('description', '')}" + ) + planned_count += 1 + if planned_count == 0: + lines.append("*No planned features yet.*") + lines.append("") + + # Add CSS styling + lines.append( + """""" + ) + + return "\n".join(lines) diff --git a/advchksys/scripts/track_progress/git_analyzer.py b/advchksys/scripts/track_progress/git_analyzer.py new file mode 100644 index 0000000..04b2283 --- /dev/null +++ b/advchksys/scripts/track_progress/git_analyzer.py @@ -0,0 +1,238 @@ +import os +import re +import subprocess +from datetime import datetime +from typing import Dict, Any, List, Optional, Tuple + + +class GitAnalyzer: + """Analyze Git repository for project status information.""" + + def __init__(self, repo_path: str = "."): + """Initialize with repository path.""" + self.repo_path = repo_path + + def _run_git_command(self, command: List[str]) -> str: + """Run a Git command and return the output.""" + try: + result = subprocess.run( + ["git"] + command, + cwd=self.repo_path, + capture_output=True, + text=True, + check=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError: + return "" + + def get_repo_info(self) -> Dict[str, Any]: + """Get basic repository information.""" + info = {} + + # Get remote URL + remote_url = self._run_git_command(["remote", "get-url", "origin"]) + info["remote_url"] = remote_url + + # Get current branch + branch = self._run_git_command(["branch", "--show-current"]) + info["branch"] = branch + + # Get last commit + last_commit = self._run_git_command( + ["log", "-1", "--pretty=format:%h|%s|%ad|%an", "--date=short"] + ) + if last_commit: + parts = last_commit.split("|") + if len(parts) >= 4: + info["last_commit"] = { + "hash": parts[0], + "message": parts[1], + "date": parts[2], + "author": parts[3], + } + + return info + + def analyze_commits( + self, limit: int = 100, untagged_limit: int = 50 + ) -> Dict[str, Any]: + """Analyze Git commits for feature updates, changelog entries, etc.""" + result = { + "feature_updates": {}, + "new_features": {}, + "changelog_entries": [], + "fixes": [], + "issues": [], + "untagged_commits": [], + "milestones": {}, + "roadmap_items": {}, + } + + # Regular expressions for parsing commit messages + status_re = re.compile(r"\[status:(\w+)\]") + feature_re = re.compile(r"\[feature:(\w+)\]") + new_feature_re = re.compile(r"\[new-feature:(\w+):(.+?)\]") + changelog_re = re.compile(r"\[changelog:(.+?)\]") + fix_re = re.compile(r"\[fix:(.+?)\]") + issue_re = re.compile(r"\[issue:(.+?)\]") + milestone_re = re.compile(r"\[milestone:(\w+)\]") + roadmap_re = re.compile(r"\[roadmap:(\w+):(.+?)\]") + + # Get Git log + git_log = self._run_git_command( + [ + "log", + f"-n{limit}", + "--pretty=format:%h|%s|%ad|%an", + "--date=short", + ] + ) + + untagged_count = 0 + + # Parse commits + for line in git_log.splitlines(): + parts = line.split("|") + if len(parts) < 4: + continue + + commit_hash, subject, date, author = parts + + # Check for tags + status_match = status_re.search(subject) + feature_match = feature_re.search(subject) + new_feature_match = new_feature_re.search(subject) + changelog_match = changelog_re.search(subject) + fix_match = fix_re.search(subject) + issue_match = issue_re.search(subject) + milestone_match = milestone_re.search(subject) + roadmap_match = roadmap_re.search(subject) + + # Process status updates + if status_match: + status_key = status_match.group(1) + result["feature_updates"][status_key] = { + "status": "completed", + "date": date, + "author": author, + } + + # Process feature updates + if feature_match: + feature_key = feature_match.group(1) + result["feature_updates"][feature_key] = { + "status": "completed", + "date": date, + "author": author, + } + + # Process new features + if new_feature_match: + feature_key = new_feature_match.group(1) + feature_desc = new_feature_match.group(2) + result["new_features"][feature_key] = { + "status": "planned", + "description": feature_desc, + "date": date, + "author": author, + } + + # Process changelog entries + if changelog_match: + result["changelog_entries"].append( + { + "message": changelog_match.group(1), + "date": date, + "commit": commit_hash, + "author": author, + } + ) + + # Process fixes + if fix_match: + result["fixes"].append( + { + "message": fix_match.group(1), + "date": date, + "commit": commit_hash, + "author": author, + } + ) + + # Process issues + if issue_match: + result["issues"].append( + { + "message": issue_match.group(1), + "date": date, + "commit": commit_hash, + "author": author, + } + ) + + # Process milestones + if milestone_match: + milestone_key = milestone_match.group(1) + if milestone_key not in result["milestones"]: + result["milestones"][milestone_key] = { + "first_date": date, + "last_date": date, + "commits": [], + } + else: + result["milestones"][milestone_key]["last_date"] = date + + result["milestones"][milestone_key]["commits"].append( + commit_hash + ) + + # Process roadmap items + if roadmap_match: + milestone_key = roadmap_match.group(1) + item_desc = roadmap_match.group(2) + + if milestone_key not in result["roadmap_items"]: + result["roadmap_items"][milestone_key] = [] + + result["roadmap_items"][milestone_key].append( + { + "description": item_desc, + "date": date, + "commit": commit_hash, + "author": author, + } + ) + + # Process untagged commits + if not any( + [ + status_match, + feature_match, + new_feature_match, + changelog_match, + fix_match, + issue_match, + milestone_match, + roadmap_match, + ] + ): + if untagged_count < untagged_limit: + # Skip merge commits and very short messages + if not subject.startswith("Merge ") and len(subject) > 5: + # Extract the first sentence or up to 100 chars + commit_desc = subject.split(".")[0] + if len(commit_desc) > 100: + commit_desc = commit_desc[:97] + "..." + + result["untagged_commits"].append( + { + "message": commit_desc, + "date": date, + "commit": commit_hash, + "author": author, + } + ) + untagged_count += 1 + + return result diff --git a/advchksys/scripts/track_progress/progress_config.json b/advchksys/scripts/track_progress/progress_config.json new file mode 100644 index 0000000..dc927a9 --- /dev/null +++ b/advchksys/scripts/track_progress/progress_config.json @@ -0,0 +1,37 @@ +{ + "project_name": "AdvChkSys", + "output_dir": "docs/status", + "status_doc": "ChunkManager-Status.md", + "features_file": "docs/features.md", + "changelog_file": "CHANGELOG.md", + "git_log_limit": 100, + "untagged_commit_limit": 50, + "top_files_limit": 20, + "exclude_dirs": [ + "node_modules", + "venv", + ".git", + ".vs", + "bin", + "obj", + "dist", + "build" + ], + "source_extensions": [ + ".py", + ".cs", + ".js", + ".ts", + ".html", + ".css", + ".rs", + ".go", + ".java", + ".md" + ], + "templates": { + "status": "status_template.md", + "features": "feature_template.md", + "changelog": "changelog_template.md" + } +} \ No newline at end of file diff --git a/advchksys/scripts/track_progress/requirements.txt b/advchksys/scripts/track_progress/requirements.txt new file mode 100644 index 0000000..6540481 --- /dev/null +++ b/advchksys/scripts/track_progress/requirements.txt @@ -0,0 +1,16 @@ +# Core dependencies +argparse # For parsing command-line arguments +shutil # For file and directory operations +GitPython # For Git repository analysis +Jinja2 # Alternative template engine (optional) +Markdown # For Markdown processing (optional) + +# Build dependencies (only needed for building the executable) +nuitka # For compiling Python to executable +ordered-set # Required by Nuitka +zstandard # For better compression in Nuitka builds + +# Development dependencies (optional) +black # Code formatting +pylint # Code linting +pytest # Testing \ No newline at end of file diff --git a/advchksys/scripts/track_progress/roadmap_generator.py b/advchksys/scripts/track_progress/roadmap_generator.py new file mode 100644 index 0000000..b56d766 --- /dev/null +++ b/advchksys/scripts/track_progress/roadmap_generator.py @@ -0,0 +1,33 @@ +""" +Roadmap generator for project status documentation. +""" + +from typing import Dict, Any, List, Optional + + +def generate_roadmap( + milestones: Dict[str, Dict[str, Any]], + roadmap_items: Dict[str, List[Dict[str, Any]]], +) -> List[Dict[str, Any]]: + """Generate roadmap from milestones and roadmap items.""" + roadmap = [] + + # Process each milestone + for milestone_key, milestone_data in milestones.items(): + # Create milestone entry + milestone = { + "name": milestone_key.replace("_", " ").title(), + "target_date": milestone_data.get("last_date", "TBD"), + "items": [], + } + + # Add roadmap items for this milestone + if milestone_key in roadmap_items: + for item in roadmap_items[milestone_key]: + milestone["items"].append( + {"description": item["description"], "status": "planned"} + ) + + roadmap.append(milestone) + + return roadmap diff --git a/advchksys/scripts/track_progress/scripts/SCRIPTS.md b/advchksys/scripts/track_progress/scripts/SCRIPTS.md new file mode 100644 index 0000000..cf6bbee --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/SCRIPTS.md @@ -0,0 +1,55 @@ +# Progress Tracker Scripts + +This directory contains utility scripts for building, installing, and running the progress tracker. + +## Available Scripts + +### setup_install.py + +Installs the progress tracker to your project. + +```bash +python setup_install.py --dest scripts --project "Your Project Name" +``` + +Options: +- `--dest folder`: Set destination directory (default: "scripts") +- `--project name`: Set project name (default: "Project") +- `--verbose` or `-v`: Enable verbose output +- `--force` or `-f`: Force overwrite existing files + +### build_with_nuitka.py + +Builds the progress tracker into a standalone executable using Nuitka. + +```bash +python build_with_nuitka.py +``` + +Options: +- `--output-dir`: Output directory for the build (default: "dist") +- `--verbose` or `-v`: Enable verbose output + +### create_launcher_scripts.py + +Creates launcher scripts for running the progress tracker. This is usually called by setup_install.py. + +## Launcher Scripts + +The following launcher scripts are created during installation: + +- `run_progress_tracker.py`: Python launcher script +- `run_progress_tracker.sh`: Shell launcher script (Linux/macOS) +- `run_progress_tracker.bat`: Batch launcher script (Windows) + +## Usage Example + +1. Install the tracker: + ```bash + python setup_install.py --project "My Project" --verbose + ``` + +2. Run the tracker using one of the launcher scripts: + - Windows: `run_progress_tracker.bat` + - Linux/macOS: `./run_progress_tracker.sh` + - Any platform: `python run_progress_tracker.py` \ No newline at end of file diff --git a/advchksys/scripts/track_progress/scripts/build_with_nuitka.py b/advchksys/scripts/track_progress/scripts/build_with_nuitka.py new file mode 100644 index 0000000..f920509 --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/build_with_nuitka.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +Build the progress tracker with Nuitka. +""" +import os +import sys +import shutil +import subprocess +import argparse + + +def main(): + """Build the progress tracker with Nuitka.""" + parser = argparse.ArgumentParser( + description="Build the progress tracker with Nuitka" + ) + parser.add_argument( + "--output-dir", default="dist", help="Output directory for the build" + ) + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable verbose output" + ) + parser.add_argument( + "--move-exe", + action="store_true", + help="Move executable to parent directory after build", + ) + args = parser.parse_args() + + verbose = args.verbose + output_dir = args.output_dir + move_exe = args.move_exe + + # Get the directory of this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(script_dir) # scripts/track_progress + + # Check if Nuitka is installed + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "show", "nuitka"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except subprocess.CalledProcessError: + print("Nuitka is not installed. Installing...") + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "nuitka"] + ) + except subprocess.CalledProcessError: + print( + "Failed to install Nuitka. Please install it manually with 'pip install nuitka'." + ) + return 1 + + # Create output directory + dist_dir = os.path.join(parent_dir, output_dir) + os.makedirs(dist_dir, exist_ok=True) + + # Path to the main script + main_script = os.path.join(parent_dir, "track_progress.py") + + if not os.path.exists(main_script): + print(f"Error: Main script not found at {main_script}") + return 1 + + # Build command + cmd = [ + sys.executable, + "-m", + "nuitka", + "--standalone", + "--onefile", + "--lto=auto", + "--jobs=4", + "--include-package=track_progress", + "--include-data-dir=" + + os.path.join(parent_dir, "templates") + + "=templates", + "--output-dir=" + dist_dir, + main_script, + ] + + # Try to enable UPX if available + try: + subprocess.check_call( + ["upx", "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + cmd.append("--enable-plugin=upx") + except (subprocess.CalledProcessError, FileNotFoundError): + if verbose: + print("UPX not found, continuing without compression") + + if verbose: + print(f"Building with command: {' '.join(cmd)}") + else: + cmd.append("--quiet") + + # Run Nuitka + try: + subprocess.check_call(cmd) + print(f"Build successful! Executable is in {dist_dir}") + + # Move the executable to the parent directory if requested + if move_exe: + exe_ext = ".exe" if sys.platform == "win32" else "" + exe_name = f"track_progress{exe_ext}" + src_exe = os.path.join(dist_dir, exe_name) + dst_exe = os.path.join(parent_dir, exe_name) + + if os.path.exists(src_exe): + # Remove existing executable if it exists + if os.path.exists(dst_exe): + os.remove(dst_exe) + + # Move the executable + shutil.move(src_exe, dst_exe) + if verbose: + print(f"Moved executable from {src_exe} to {dst_exe}") + else: + print(f"Warning: Built executable not found at {src_exe}") + + return 0 + except subprocess.CalledProcessError as e: + print(f"Build failed: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py b/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py new file mode 100644 index 0000000..89c1e47 --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/create_launcher_scripts.py @@ -0,0 +1,217 @@ +""" +Create launcher scripts for the progress tracker. +""" + +import os +import sys +from typing import List, Tuple + + +def create_python_launcher( + output_path: str, dest_dir: str, project_name: str +) -> bool: + """Create a Python launcher script.""" + content = f"""#!/usr/bin/env python3 +\"\"\" +Launcher for the progress tracker. +Tries to use the executable version first, then falls back to the Python version. +\"\"\" +import os +import sys +import subprocess + +def main(): + \"\"\"Main function to run the tracker.\"\"\" + # Get the directory of this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + tracker_dir = os.path.dirname(script_dir) + + # Paths to executable and Python script + exe_ext = ".exe" if sys.platform == "win32" else "" + exe_path = os.path.join(tracker_dir, f"track_progress{{exe_ext}}") + py_path = os.path.join(tracker_dir, "track_progress.py") + + # Command-line arguments to pass through + args = sys.argv[1:] + + # Try executable first + if os.path.exists(exe_path) and (sys.platform == "win32" or os.access(exe_path, os.X_OK)): + print("Running Progress Tracker (executable version)...") + try: + return subprocess.call([exe_path] + args) + except Exception as e: + print(f"Error running executable: {{e}}") + print("Falling back to Python version...") + else: + print("Executable not found, trying Python version...") + + # Fall back to Python script + if os.path.exists(py_path): + print("Running Progress Tracker (Python version)...") + try: + # Add the tracker directory to the Python path + sys.path.insert(0, tracker_dir) + + # Change to the tracker directory + os.chdir(tracker_dir) + + # Run the Python script + return subprocess.call([sys.executable, py_path] + args) + except Exception as e: + print(f"Error running Python script: {{e}}") + return 1 + else: + print("Error: Neither executable nor Python script found at:") + print(f"- {{exe_path}}") + print(f"- {{py_path}}") + return 1 + +if __name__ == "__main__": + exit_code = main() + if exit_code == 0: + print("Progress tracking completed successfully!") + else: + print(f"Error running progress tracker! (Exit code: {{exit_code}})") + + # On Windows, pause to see the output + if sys.platform == "win32": + input("Press Enter to continue...") + + sys.exit(exit_code) +""" + + try: + with open(output_path, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + return True + except Exception as e: + print(f"Error creating Python launcher: {e}") + return False + + +def create_shell_launcher(output_path: str) -> bool: + """Create a shell launcher script.""" + content = """#!/bin/bash + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRACKER_DIR="$(dirname "$SCRIPT_DIR")" +EXE_PATH="$TRACKER_DIR/track_progress" +PY_PATH="$TRACKER_DIR/track_progress.py" + +# Check if executable exists and is executable +if [ -x "$EXE_PATH" ]; then + echo "Running Progress Tracker (executable version)..." + "$EXE_PATH" "$@" +elif [ -f "$PY_PATH" ]; then + echo "Executable not found, trying Python version..." + echo "Running Progress Tracker (Python version)..." + python "$PY_PATH" "$@" +else + echo "Error: Neither executable nor Python script found at:" + echo "- $EXE_PATH" + echo "- $PY_PATH" + exit 1 +fi + +EXIT_CODE=$? +if [ $EXIT_CODE -eq 0 ]; then + echo "Progress tracking completed successfully!" +else + echo "Error running progress tracker! (Exit code: $EXIT_CODE)" +fi +""" + + try: + with open(output_path, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + + # Make the script executable on Unix-like systems + if sys.platform != "win32": + os.chmod(output_path, 0o755) + + return True + except Exception as e: + print(f"Error creating shell launcher: {e}") + return False + + +def create_batch_launcher(output_path: str) -> bool: + """Create a batch launcher script.""" + content = """@echo off +setlocal enabledelayedexpansion + +REM Get the directory of this script +set "SCRIPT_DIR=%~dp0" +set "TRACKER_DIR=%SCRIPT_DIR%.." +set "EXE_PATH=%TRACKER_DIR%\\track_progress.exe" +set "PY_PATH=%TRACKER_DIR%\\track_progress.py" + +REM Check if executable exists +if exist "%EXE_PATH%" ( + echo Running Progress Tracker (executable version)... + "%EXE_PATH%" %* +) else ( + echo Executable not found, trying Python version... + if exist "%PY_PATH%" ( + echo Running Progress Tracker (Python version)... + python "%PY_PATH%" %* + ) else ( + echo Error: Neither executable nor Python script found at: + echo - %EXE_PATH% + echo - %PY_PATH% + exit /b 1 + ) +) + +if %ERRORLEVEL% EQU 0 ( + echo Progress tracking completed successfully! +) else ( + echo Error running progress tracker! (Exit code: %ERRORLEVEL%) +) +pause +""" + + try: + with open(output_path, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + return True + except Exception as e: + print(f"Error creating batch launcher: {e}") + return False + + +def ensure_launcher_scripts( + script_dir: str, + dest_dir: str, + project_name: str, + verbose: bool = False, + force: bool = False, +) -> List[str]: + """Ensure all launcher scripts exist in the script directory.""" + created_scripts = [] + + # Define the scripts to check/create + scripts_to_ensure = [ + ("run_progress_tracker.py", create_python_launcher), + ("run_progress_tracker.sh", create_shell_launcher), + ("run_progress_tracker.bat", create_batch_launcher), + ] + + for script_name, create_func in scripts_to_ensure: + script_path = os.path.join(script_dir, script_name) + + if not os.path.exists(script_path) or force: + if verbose: + print(f"Creating launcher script: {script_path}") + + if create_func(script_path, dest_dir, project_name): + created_scripts.append(script_path) + if verbose: + print(f"Created {script_path}") + else: + print(f"Failed to create {script_path}") + elif verbose: + print(f"Launcher script already exists: {script_path}") + + return created_scripts diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat b/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat new file mode 100644 index 0000000..6bfcef8 --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.bat @@ -0,0 +1,32 @@ +@echo off +setlocal enabledelayedexpansion + +REM Get the directory of this script +set "SCRIPT_DIR=%~dp0" +set "TRACKER_DIR=%SCRIPT_DIR%.." +set "EXE_PATH=%TRACKER_DIR%\track_progress.exe" +set "PY_PATH=%TRACKER_DIR%\track_progress.py" + +REM Check if executable exists +if exist "%EXE_PATH%" ( + echo Running Progress Tracker (executable version)... + "%EXE_PATH%" %* +) else ( + echo Executable not found, trying Python version... + if exist "%PY_PATH%" ( + echo Running Progress Tracker (Python version)... + python "%PY_PATH%" %* + ) else ( + echo Error: Neither executable nor Python script found at: + echo - %EXE_PATH% + echo - %PY_PATH% + exit /b 1 + ) +) + +if %ERRORLEVEL% EQU 0 ( + echo Progress tracking completed successfully! +) else ( + echo Error running progress tracker! (Exit code: %ERRORLEVEL%) +) +pause \ No newline at end of file diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.py b/advchksys/scripts/track_progress/scripts/run_progress_tracker.py new file mode 100644 index 0000000..b96f27e --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +Launcher for the progress tracker. +Tries to use the executable version first, then falls back to the Python version. +""" +import os +import sys +import subprocess + + +def main(): + """Main function to run the tracker.""" + # Get the directory of this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + tracker_dir = os.path.dirname(script_dir) + + # Paths to executable and Python script + exe_ext = ".exe" if sys.platform == "win32" else "" + exe_path = os.path.join(tracker_dir, f"track_progress{exe_ext}") + py_path = os.path.join(tracker_dir, "track_progress.py") + + # Command-line arguments to pass through + args = sys.argv[1:] + + # Try executable first + if os.path.exists(exe_path) and ( + sys.platform == "win32" or os.access(exe_path, os.X_OK) + ): + print("Running Progress Tracker (executable version)...") + try: + return subprocess.call([exe_path] + args) + except Exception as e: + print(f"Error running executable: {e}") + print("Falling back to Python version...") + else: + print("Executable not found, trying Python version...") + + # Fall back to Python script + if os.path.exists(py_path): + print("Running Progress Tracker (Python version)...") + try: + # Add the tracker directory to the Python path + sys.path.insert(0, tracker_dir) + + # Change to the tracker directory + os.chdir(tracker_dir) + + # Run the Python script + return subprocess.call([sys.executable, py_path] + args) + except Exception as e: + print(f"Error running Python script: {e}") + return 1 + else: + print("Error: Neither executable nor Python script found at:") + print(f"- {exe_path}") + print(f"- {py_path}") + return 1 + + +if __name__ == "__main__": + exit_code = main() + if exit_code == 0: + print("Progress tracking completed successfully!") + else: + print(f"Error running progress tracker! (Exit code: {exit_code})") + + # On Windows, pause to see the output + if sys.platform == "win32": + input("Press Enter to continue...") + + sys.exit(exit_code) diff --git a/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh b/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh new file mode 100644 index 0000000..5dc513b --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/run_progress_tracker.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Get the directory of this script +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TRACKER_DIR="$(dirname "$SCRIPT_DIR")" +EXE_PATH="$TRACKER_DIR/track_progress" +PY_PATH="$TRACKER_DIR/track_progress.py" + +# Check if executable exists and is executable +if [ -x "$EXE_PATH" ]; then + echo "Running Progress Tracker (executable version)..." + "$EXE_PATH" "$@" +elif [ -f "$PY_PATH" ]; then + echo "Executable not found, trying Python version..." + echo "Running Progress Tracker (Python version)..." + python "$PY_PATH" "$@" +else + echo "Error: Neither executable nor Python script found at:" + echo "- $EXE_PATH" + echo "- $PY_PATH" + exit 1 +fi + +EXIT_CODE=$? +if [ $EXIT_CODE -eq 0 ]; then + echo "Progress tracking completed successfully!" +else + echo "Error running progress tracker! (Exit code: $EXIT_CODE)" +fi diff --git a/advchksys/scripts/track_progress/scripts/setup_install.py b/advchksys/scripts/track_progress/scripts/setup_install.py new file mode 100644 index 0000000..edbd619 --- /dev/null +++ b/advchksys/scripts/track_progress/scripts/setup_install.py @@ -0,0 +1,285 @@ +""" Run this to install to your projects + | "--dest folder" |to set destination dir + | "--project name" |to set project name + | "--verbose" or "-v"|to enable verbose output + | "--force" or "-f"|to force overwrite existing files +""" + +import os +import sys +import shutil +import argparse +import subprocess +from pathlib import Path + +# Try to import our script creation module +try: + from create_launcher_scripts import ensure_launcher_scripts +except ImportError: + # If we can't import it, we'll define a simple version here + def ensure_launcher_scripts( + script_dir, dest_dir, project_name, verbose=False, force=False + ): + """Fallback function if the module can't be imported.""" + print( + "Warning: create_launcher_scripts module not found. Using fallback." + ) + return [] + + +def main(): + """Main installation function.""" + parser = argparse.ArgumentParser( + description="Install the progress tracking system." + ) + parser.add_argument( + "--dest", default="scripts", help="Destination directory" + ) + parser.add_argument("--project", default="Project", help="Project name") + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable verbose output" + ) + parser.add_argument( + "--force", + "-f", + action="store_true", + help="Force overwrite existing files", + ) + args = parser.parse_args() + + verbose = args.verbose + force = args.force + + if verbose: + print( + f"Installing progress tracking system to {args.dest} for project {args.project}" + ) + + # Create destination directories + os.makedirs( + os.path.join(args.dest, "track_progress", "templates"), exist_ok=True + ) + os.makedirs("docs/status", exist_ok=True) + + # Get the directory of this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(script_dir) # scripts/track_progress + + # Look for compiled executable + exe_ext = ".exe" if sys.platform == "win32" else "" + compiled_exe = os.path.join(parent_dir, f"track_progress{exe_ext}") + dist_exe = os.path.join(parent_dir, "dist", f"track_progress{exe_ext}") + + # Check if we have the executable in either location + if not os.path.exists(compiled_exe) and not os.path.exists(dist_exe): + # Try to build it + if verbose: + print(f"Compiled executable not found, trying to build it...") + + build_script = os.path.join(script_dir, "build_with_nuitka.py") + if os.path.exists(build_script): + build_cmd = [sys.executable, build_script] + if verbose: + build_cmd.append("--verbose") + + try: + subprocess.check_call(build_cmd) + # The executable should now be in the dist directory + + # Move the executable from dist to parent directory + if os.path.exists(dist_exe): + if os.path.exists(compiled_exe): + os.remove(compiled_exe) + shutil.move(dist_exe, compiled_exe) + if verbose: + print( + f"Moved executable from {dist_exe} to {compiled_exe}" + ) + except subprocess.CalledProcessError: + print( + "Failed to build the executable. Falling back to Python files." + ) + else: + print("Build script not found. Falling back to Python files.") + + # Check if we have the executable now + have_executable = os.path.exists(compiled_exe) + + if have_executable: + # Copy the compiled executable + dst_exe = os.path.join( + args.dest, "track_progress", f"track_progress{exe_ext}" + ) + if not os.path.exists(dst_exe) or force: + shutil.copy2(compiled_exe, dst_exe) + if verbose: + print( + f"Copied compiled executable from {compiled_exe} to {dst_exe}" + ) + elif verbose: + print(f"Skipping existing executable: {dst_exe}") + else: + # Copy individual Python files + python_files = [ + "track_progress.py", + "config.py", + "git_analyzer.py", + "code_stats.py", + "feature_markdown.py", + "template_engine.py", + "changelog_generator.py", + "roadmap_generator.py", + "__init__.py", # Add this to make it a proper package + "README.md", + ] + + for file in python_files: + src = os.path.join(parent_dir, file) + dst = os.path.join(args.dest, "track_progress", file) + + # Skip existing files unless force is specified + if os.path.exists(dst) and not force: + if verbose: + print(f"Skipping existing file: {dst}") + continue + + if os.path.exists(src): + shutil.copy2(src, dst) + if verbose: + print(f"Copied {file} to {dst}") + elif file == "__init__.py": + # Create empty __init__.py if it doesn't exist + with open(dst, "w", encoding="utf-8") as f: + f.write( + "# This file makes the directory a Python package\n" + ) + if verbose: + print(f"Created {dst}") + + # Copy templates (always copy templates, they should remain exposed) + template_files = [ + "status_template.md", + "feature_template.md", + "changelog_template.md", + ] + + for file in template_files: + src = os.path.join(parent_dir, "templates", file) + dst = os.path.join(args.dest, "track_progress", "templates", file) + + # Skip existing files unless force is specified + if os.path.exists(dst) and not force: + if verbose: + print(f"Skipping existing template: {dst}") + continue + + if os.path.exists(src): + shutil.copy2(src, dst) + if verbose: + print(f"Copied template {file} to {dst}") + + # Copy and customize configuration + config_src = os.path.join(parent_dir, "progress_config.json") + config_dst = os.path.join( + args.dest, "track_progress", "progress_config.json" + ) + + # Skip existing config unless force is specified + if not os.path.exists(config_dst) or force: + if os.path.exists(config_src): + # Read the config file + with open(config_src, "r", encoding="utf-8") as f: + config_content = f.read() + + # Replace project name + config_content = config_content.replace( + '"project_name": "AdvChkSys"', + f'"project_name": "{args.project}"', + ) + + # Ensure paths are relative to the project root + config_content = config_content.replace( + '"features_file": "docs/features.md"', + '"features_file": "docs/features.md"', + ) + config_content = config_content.replace( + '"output_dir": "docs/status"', + '"output_dir": "docs/status"', + ) + config_content = config_content.replace( + '"changelog_file": "CHANGELOG.md"', + '"changelog_file": "CHANGELOG.md"', + ) + + # Write the customized config + with open(config_dst, "w", encoding="utf-8") as f: + f.write(config_content) + + if verbose: + print(f"Created customized configuration at {config_dst}") + elif verbose: + print(f"Skipping existing configuration: {config_dst}") + + # Ensure launcher scripts exist in the scripts directory + created_scripts = ensure_launcher_scripts( + script_dir, args.dest, args.project, verbose, force + ) + if created_scripts and verbose: + print( + f"Created {len(created_scripts)} launcher scripts in {script_dir}" + ) + + # Create an empty CHANGELOG.md if it doesn't exist + if not os.path.exists("CHANGELOG.md"): + with open("CHANGELOG.md", "w", encoding="utf-8") as f: + f.write("# Changelog\n\n") + if verbose: + print("Created empty CHANGELOG.md") + + # Copy the scripts directory to the destination if it doesn't exist + scripts_dst = os.path.join(args.dest, "track_progress", "scripts") + if not os.path.exists(scripts_dst) or force: + os.makedirs(scripts_dst, exist_ok=True) + + # Copy script files + script_files = [ + "build_with_nuitka.py", + "create_launcher_scripts.py", + "setup_install.py", + ] + + for file in script_files: + src = os.path.join(script_dir, file) + dst = os.path.join(scripts_dst, file) + + if os.path.exists(src) and (not os.path.exists(dst) or force): + shutil.copy2(src, dst) + if verbose: + print(f"Copied script {file} to {dst}") + + print("\nInstallation complete!") + if have_executable: + print( + f"Compiled tracker installed to: {os.path.join(args.dest, 'track_progress')}" + ) + else: + print( + f"Python tracker installed to: {os.path.join(args.dest, 'track_progress')}" + ) + + # Check which launcher scripts exist + py_launcher = os.path.join(script_dir, "run_progress_tracker.py") + sh_launcher = os.path.join(script_dir, "run_progress_tracker.sh") + bat_launcher = os.path.join(script_dir, "run_progress_tracker.bat") + + print("\nTo run the tracker, use one of these commands:") + if os.path.exists(py_launcher): + print(f"- Python: python {py_launcher}") + if os.path.exists(sh_launcher) and sys.platform != "win32": + print(f"- Shell: {sh_launcher}") + if os.path.exists(bat_launcher) and sys.platform == "win32": + print(f"- Batch: {bat_launcher}") + + +if __name__ == "__main__": + main() diff --git a/advchksys/scripts/track_progress/template_engine.py b/advchksys/scripts/track_progress/template_engine.py new file mode 100644 index 0000000..b83849a --- /dev/null +++ b/advchksys/scripts/track_progress/template_engine.py @@ -0,0 +1,239 @@ +""" +Template engine for generating Markdown documentation from templates. +""" + +import os +import re +import sys +from typing import Dict, Any, List, Optional + + +class TemplateEngine: + """Simple template engine for generating Markdown files.""" + + def __init__( + self, templates_dir: str = "scripts/track_progress/templates" + ): + """Initialize the template engine with the templates directory.""" + self.templates_dir = templates_dir + + def _load_template(self, template_name: str) -> str: + """Load a template file.""" + # Try multiple possible template locations + possible_paths = [ + # Original path + os.path.join(self.templates_dir, template_name), + # Path relative to current working directory + os.path.join("templates", template_name), + # Path relative to executable directory + os.path.join( + os.path.dirname(sys.executable), "templates", template_name + ), + # Path relative to script directory + os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "templates", + template_name, + ), + # Path with 'scripts/track_progress' removed (for compiled version) + template_name.replace("scripts/track_progress/", ""), + # Just the filename + template_name, + ] + + # Try each path + for template_path in possible_paths: + if os.path.exists(template_path): + with open(template_path, "r", encoding="utf-8") as f: + return f.read() + + # If we get here, none of the paths worked + error_message = f"Template not found: {template_name}\nTried paths: {possible_paths}" + raise FileNotFoundError(error_message) + + def _replace_variables( + self, template: str, context: Dict[str, Any] + ) -> str: + """Replace {{ variable }} with values from context.""" + + def replace_var(match): + var_name = match.group(1).strip() + + # Handle nested variables with dot notation + if "." in var_name: + parts = var_name.split(".") + value = context + for part in parts: + if isinstance(value, dict) and part in value: + value = value[part] + else: + return match.group(0) # Return original if not found + return str(value) if value is not None else "" + + # Handle simple variables + if var_name in context: + return ( + str(context[var_name]) + if context[var_name] is not None + else "" + ) + + return match.group(0) # Return original if not found + + # Replace {{ variable }} + pattern = r"{{(.*?)}}" + return re.sub(pattern, replace_var, template) + + def _process_conditionals( + self, template: str, context: Dict[str, Any] + ) -> str: + """Process {% if condition %} ... {% endif %} blocks.""" + + def replace_conditional(match): + condition_var = match.group(1).strip() + content = match.group(2) + + # Handle nested conditions with dot notation + if "." in condition_var: + parts = condition_var.split(".") + value = context + for part in parts: + if isinstance(value, dict) and part in value: + value = value[part] + else: + return "" # Condition not met + + if value: + return content + return "" + + # Handle simple conditions + if condition_var in context and context[condition_var]: + return content + return "" + + # Process {% if condition %} ... {% endif %} + pattern = r"{%\s*if\s+(.*?)\s*%}(.*?){%\s*endif\s*%}" + return re.sub(pattern, replace_conditional, template, flags=re.DOTALL) + + def _process_loops(self, template: str, context: Dict[str, Any]) -> str: + """Process {% for item_var, item in items.items() %} ... {% endfor %} blocks.""" + + # First, process dictionary iteration with items() + def replace_dict_loop(match): + item_key_var = match.group(1).strip() + item_val_var = match.group(2).strip() + collection_var = match.group(3).strip() + content_template = match.group(4) + + # Handle nested collections with dot notation + if "." in collection_var: + parts = collection_var.split(".") + collection = context + for part in parts: + if isinstance(collection, dict) and part in collection: + collection = collection[part] + else: + return "" # Collection not found + else: + collection = context.get(collection_var, {}) + + if not isinstance(collection, dict): + return "" # Not a dictionary + + result = [] + for key, value in collection.items(): + # Create a new context for each iteration + loop_context = context.copy() + loop_context[item_key_var] = key + loop_context[item_val_var] = value + + # Process the content template with the loop context + item_content = self._replace_variables( + content_template, loop_context + ) + item_content = self._process_conditionals( + item_content, loop_context + ) + result.append(item_content) + + return "".join(result) + + # Process {% for key, value in dict.items() %} ... {% endfor %} + dict_pattern = r"{%\s*for\s+(.*?),\s*(.*?)\s+in\s+(.*?)\.items\(\)\s*%}(.*?){%\s*endfor\s*%}" + template = re.sub( + dict_pattern, replace_dict_loop, template, flags=re.DOTALL + ) + + # Then, process regular list iteration + def replace_list_loop(match): + item_var = match.group(1).strip() + collection_var = match.group(2).strip() + content_template = match.group(3) + + # Handle nested collections with dot notation + if "." in collection_var: + parts = collection_var.split(".") + collection = context + for part in parts: + if isinstance(collection, dict) and part in collection: + collection = collection[part] + else: + return "" # Collection not found + else: + collection = context.get(collection_var, []) + + if not isinstance(collection, (list, dict)): + return "" # Not a collection + + result = [] + if isinstance(collection, dict): + # If it's a dict, iterate over keys + for key in collection: + # Create a new context for each iteration + loop_context = context.copy() + loop_context[item_var] = key + + # Process the content template with the loop context + item_content = self._replace_variables( + content_template, loop_context + ) + item_content = self._process_conditionals( + item_content, loop_context + ) + result.append(item_content) + else: # list + for item in collection: + # Create a new context for each iteration + loop_context = context.copy() + loop_context[item_var] = item + + # Process the content template with the loop context + item_content = self._replace_variables( + content_template, loop_context + ) + item_content = self._process_conditionals( + item_content, loop_context + ) + result.append(item_content) + + return "".join(result) + + # Process {% for item in items %} ... {% endfor %} + list_pattern = ( + r"{%\s*for\s+(.*?)\s+in\s+(.*?)\s*%}(.*?){%\s*endfor\s*%}" + ) + return re.sub( + list_pattern, replace_list_loop, template, flags=re.DOTALL + ) + + def render(self, template_name: str, context: Dict[str, Any]) -> str: + """Render a template with the given context.""" + template = self._load_template(template_name) + + # Process template directives in order + template = self._process_loops(template, context) + template = self._process_conditionals(template, context) + template = self._replace_variables(template, context) + + return template diff --git a/advchksys/scripts/track_progress/templates/changelog_template.md b/advchksys/scripts/track_progress/templates/changelog_template.md new file mode 100644 index 0000000..ce46bc3 --- /dev/null +++ b/advchksys/scripts/track_progress/templates/changelog_template.md @@ -0,0 +1,7 @@ +# {{ project_name }} Changelog + +Last updated: {{ current_date }} + +{% for entry in changelog_entries %} +{{ entry }} +{% endfor %} \ No newline at end of file diff --git a/advchksys/scripts/track_progress/templates/feature_template.md b/advchksys/scripts/track_progress/templates/feature_template.md new file mode 100644 index 0000000..31ada23 --- /dev/null +++ b/advchksys/scripts/track_progress/templates/feature_template.md @@ -0,0 +1,92 @@ +# {{ title }} + +## Overview + +This document tracks the implementation status of all features in the {{ project_name }} project. + +## Feature Status Summary + +| Status | Description | +|--------|-------------| +| ✅ Completed | Features that are fully implemented and tested | +| 🔄 In Progress | Features currently being implemented | +| 📝 Planned | Features planned for future implementation | + +## Features + +{% for feature_key, feature in features.items() %} +### {{ feature_key }} + +**Status:** {{ feature.status_emoji }} {{ feature.status }} +**Description:** {{ feature.description }} +**Last Update:** {{ feature.date }} +{% if feature.author %}**Owner:** {{ feature.author }}{% endif %} +{% if feature.details %}**Details:** {{ feature.details }}{% endif %} + +{% endfor %} + +## Feature Categories + +### Completed Features + +{% for feature_key, feature in features.items() %} +{% if feature.status == 'completed' %} +- **{{ feature_key }}**: {{ feature.description }} +{% endif %} +{% endfor %} + +### In Progress Features + +{% for feature_key, feature in features.items() %} +{% if feature.status == 'in_progress' %} +- **{{ feature_key }}**: {{ feature.description }} +{% endif %} +{% endfor %} + +### Planned Features + +{% for feature_key, feature in features.items() %} +{% if feature.status == 'planned' %} +- **{{ feature_key }}**: {{ feature.description }} +{% endif %} +{% endfor %} + + \ No newline at end of file diff --git a/advchksys/scripts/track_progress/templates/status_template.md b/advchksys/scripts/track_progress/templates/status_template.md new file mode 100644 index 0000000..5d8f1d3 --- /dev/null +++ b/advchksys/scripts/track_progress/templates/status_template.md @@ -0,0 +1,57 @@ +# {{ project_name }} Development Status + +Last updated: {{ current_date }} + +## Code Statistics + +{% if stats %} +Total lines of code: **{{ stats.total_lines }}** + +Number of source files: **{{ stats.file_count }}** + +### Files by Language +{% for lang, count in stats.languages.items() %} +- **{{ lang }}**: {{ count }} files +{% endfor %} + +### Top Files by Line Count + +| File | Lines | Language | Path | +|------|------:|----------|------| +{% for file in stats.top_files %} +| {{ file.name }} | {{ file.lines }} | {{ file.language }} | {{ file.path }} | +{% endfor %} +{% endif %} + +## Feature Status + +| Feature | Status | Description | Last Update | Owner | +|---------|--------|-------------|-------------|-------| +{% for feature_key, feature in features.items() %} +| {{ feature.display_name }} | {{ feature.status_emoji }} {{ feature.status }} | {{ feature.description }} | {{ feature.date }} | {{ feature.author }} | +{% endfor %} + +## Recent Updates + +{% for entry in changelog_entries %} +{{ entry }} +{% endfor %} + +{% if known_issues %} +## Known Issues + +{% for issue in known_issues %} +- {{ issue.description }} {% if issue.status %}({{ issue.status }}){% endif %} +{% endfor %} +{% endif %} + +{% if roadmap %} +## Roadmap + +{% for milestone in roadmap %} +### {{ milestone.name }} ({{ milestone.target_date }}) +{% for item in milestone.items %} +- {{ item.description }} {% if item.status %}[{{ item.status }}]{% endif %} +{% endfor %} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/advchksys/scripts/track_progress/track_progress.py b/advchksys/scripts/track_progress/track_progress.py new file mode 100644 index 0000000..55deeea --- /dev/null +++ b/advchksys/scripts/track_progress/track_progress.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +""" +Project progress tracker that generates status documentation from Git history. +""" +import os +import sys +import argparse +from datetime import datetime +from typing import Dict, Any, List, Optional + +# Add the current directory to the Python path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Import our modules +from config import Config +from code_stats import analyze_code_stats +from git_analyzer import GitAnalyzer +from feature_markdown import ( + enrich_features, + emoji_status, + extract_features, + generate_features_md, +) +from template_engine import TemplateEngine +from changelog_generator import update_changelog_file +from roadmap_generator import generate_roadmap + + +def load_features(features_file: str) -> Dict[str, Dict[str, Any]]: + """Load features from a Markdown file.""" + # Use the improved extraction function + return extract_features(features_file) + + +def update_features( + features: Dict[str, Dict[str, Any]], + feature_updates: Dict[str, Dict[str, Any]], + new_features: Dict[str, Dict[str, Any]], +) -> Dict[str, Dict[str, Any]]: + """Update features with new information from Git history.""" + updated_features = features.copy() + + # Update existing features + for feature_key, update in feature_updates.items(): + if feature_key in updated_features: + updated_features[feature_key].update(update) + + # Add new features + for feature_key, feature_data in new_features.items(): + if feature_key not in updated_features: + updated_features[feature_key] = feature_data + + return updated_features + + +def format_changelog_entries(git_data: Dict[str, Any]) -> List[str]: + """Format changelog entries from Git data.""" + entries = [] + + # Add changelog entries + for entry in git_data.get("changelog_entries", []): + entries.append( + f"- {entry['date']}: {entry['message']} ({entry['commit']})" + ) + + # Add fix entries + for fix in git_data.get("fixes", []): + entries.append( + f"- {fix['date']}: Fixed: {fix['message']} ({fix['commit']})" + ) + + # Add untagged commits + for commit in git_data.get("untagged_commits", []): + entries.append( + f"- {commit['date']}: {commit['message']} ({commit['commit']})" + ) + + return entries + + +def extract_known_issues(git_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """Extract known issues from Git data.""" + issues = [] + + # Look for issues in the Git data + for issue in git_data.get("issues", []): + issues.append( + {"description": issue.get("message", ""), "status": "open"} + ) + + return issues + + +def enrich_features( + features: Dict[str, Dict[str, Any]] +) -> Dict[str, Dict[str, Any]]: + """Add additional information to features for display purposes.""" + enriched = {} + + for key, data in features.items(): + enriched[key] = data.copy() + status = data.get("status", "unknown") + enriched[key]["status_emoji"] = emoji_status(status) + + # Add title-cased key for display + enriched[key]["display_name"] = key.replace("_", " ").title() + + return enriched + + +def main(): + """Main function to run the progress tracker.""" + parser = argparse.ArgumentParser( + description="Track project progress and generate status documentation." + ) + parser.add_argument( + "--config", + default="scripts/track_progress/progress_config.json", + help="Path to configuration file", + ) + parser.add_argument("--project", help="Project name (overrides config)") + parser.add_argument( + "--output-dir", help="Output directory (overrides config)" + ) + parser.add_argument("--repo", default=".", help="Repository path") + parser.add_argument( + "--verbose", "-v", action="store_true", help="Enable verbose output" + ) + args = parser.parse_args() + + verbose = args.verbose + + # Load configuration + config = Config(args.config) + + # Override config with command-line arguments + if args.project: + config.set("project_name", args.project) + if args.output_dir: + config.set("output_dir", args.output_dir) + + # Ensure output directory exists + output_dir = config.get("output_dir") + os.makedirs(output_dir, exist_ok=True) + + # Initialize components + git_analyzer = GitAnalyzer(args.repo) + template_engine = TemplateEngine() + + # Get repository information + repo_info = git_analyzer.get_repo_info() + + # Analyze Git history + git_data = git_analyzer.analyze_commits( + limit=config.get("git_log_limit"), + untagged_limit=config.get("untagged_commit_limit"), + ) + + # Analyze code statistics + code_stats = analyze_code_stats( + root_dir=args.repo, + exclude_dirs=config.get("exclude_dirs"), + source_extensions=config.get("source_extensions"), + top_files_limit=config.get("top_files_limit"), + ) + + # Load existing features + features_file = config.get("features_file") + if not os.path.isabs(features_file): + # If it's not an absolute path, make it relative to the repo root, not the output dir + features_file = os.path.join(args.repo, features_file) + + if verbose: + print(f"Loading features from: {features_file}") + + features = load_features(features_file) + + if verbose: + print(f"Loaded {len(features)} features") + + # Update features with Git data + features = update_features( + features, + git_data.get("feature_updates", {}), + git_data.get("new_features", {}), + ) + + if verbose: + print(f"Updated features count: {len(features)}") + + # Enrich features with emojis and other display information + enriched_features = enrich_features(features) + + # Format changelog entries + changelog_entries = format_changelog_entries(git_data) + + # Extract known issues + known_issues = extract_known_issues(git_data) + + # Generate roadmap + roadmap = generate_roadmap( + git_data.get("milestones", {}), git_data.get("roadmap_items", {}) + ) + + # Prepare template context + context = { + "project_name": config.get("project_name"), + "current_date": datetime.now().strftime("%Y-%m-%d"), + "repo_info": repo_info, + "stats": code_stats, + "features": enriched_features, + "changelog_entries": changelog_entries, + "known_issues": known_issues, + "roadmap": roadmap, + } + + # Generate status document + status_template = config.get("templates", {}).get( + "status", "status_template.md" + ) + status_content = template_engine.render(status_template, context) + + status_file = os.path.join(args.repo, output_dir, config.get("status_doc")) + os.makedirs(os.path.dirname(status_file), exist_ok=True) + with open(status_file, "w", encoding="utf-8") as f: + f.write(status_content) + + # Generate features document using the improved function + features_content = generate_features_md( + features, config.get("project_name") + ) + + # Ensure directory exists for features file + os.makedirs(os.path.dirname(features_file), exist_ok=True) + + # Create backup of original features file + if os.path.exists(features_file): + backup_file = ( + f"{features_file}.bak.{datetime.now().strftime('%Y%m%d%H%M%S')}" + ) + with open(features_file, "r", encoding="utf-8") as src: + with open(backup_file, "w", encoding="utf-8") as dst: + dst.write(src.read()) + if verbose: + print(f"Created backup: {backup_file}") + + # Write the new features file + with open(features_file, "w", encoding="utf-8") as f: + f.write(features_content) + + # Update changelog + changelog_file = config.get( + "changelog_file", "CHANGELOG.md" + ) # Default to CHANGELOG.md + + if not os.path.isabs(changelog_file): + # If it's a relative path, make it relative to the repo root, not the output dir + changelog_file = os.path.join(args.repo, changelog_file) + + if verbose: + print(f"Updating changelog file: {changelog_file}") + + # Ensure the directory exists + changelog_dir = os.path.dirname(changelog_file) + if changelog_dir: # Only create directory if there is one + os.makedirs(changelog_dir, exist_ok=True) + + update_changelog_file( + git_data.get("changelog_entries", []), + changelog_file, + max_entries=config.get("git_log_limit"), + verbose=verbose, + ) + + print(f"Progress tracking updated:") + print(f"- Status document: {status_file}") + print(f"- Features document: {features_file}") + print(f"- Changelog: {changelog_file}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj b/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj new file mode 100644 index 0000000..bca0de2 --- /dev/null +++ b/advchksys/src/AdvChkSys.Benchmarks/AdvChkSys.Benchmarks.csproj @@ -0,0 +1,24 @@ + + + + Exe + net9.0 + enable + ChunkMark + ChunkMark + + + + + + + + true + true + win-x64 + true + false + true + + + \ No newline at end of file diff --git a/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs b/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs new file mode 100644 index 0000000..f09c5c3 --- /dev/null +++ b/advchksys/src/AdvChkSys.Benchmarks/ChunkMark.cs @@ -0,0 +1,952 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text; +using AdvChkSys; +using AdvChkSys.Chunk; +using AdvChkSys.Manager; +using AdvChkSys.Constraints; +using AdvChkSys.Diagnostics; + +namespace ChunkMark +{ + public static class Program + { + private static readonly ConsoleColor _headerColor = ConsoleColor.Cyan; + private static readonly ConsoleColor _resultColor = ConsoleColor.Green; + private static readonly ConsoleColor _memoryColor = ConsoleColor.Yellow; + private static readonly ConsoleColor _errorColor = ConsoleColor.Red; + private static readonly ConsoleColor _infoColor = ConsoleColor.Gray; + + private static List _results = new List(); + private static bool _verboseMode = false; + + public static void Main(string[] args) + { + int cpuCount = Environment.ProcessorCount; + int num2DChunks = 10000; + int chunk2DSize = 32; + int num3DChunks = 500; + int chunk3DWidth = 16, chunk3DHeight = 16, chunk3DDepth = 16; + int maxLoaded = 100000; + bool run2D = true, run3D = true; + bool runMemoryTest = false; + bool runStressTest = false; + + // Parse args + foreach (var arg in args) + { + if (arg.StartsWith("--cpus=")) cpuCount = int.Parse(arg.Substring(7)); + if (arg.StartsWith("--2d-chunks=")) num2DChunks = int.Parse(arg.Substring(12)); + if (arg.StartsWith("--2d-size=")) chunk2DSize = int.Parse(arg.Substring(10)); + if (arg.StartsWith("--3d-chunks=")) num3DChunks = int.Parse(arg.Substring(12)); + if (arg.StartsWith("--3d-width=")) chunk3DWidth = int.Parse(arg.Substring(11)); + if (arg.StartsWith("--3d-height=")) chunk3DHeight = int.Parse(arg.Substring(12)); + if (arg.StartsWith("--3d-depth=")) chunk3DDepth = int.Parse(arg.Substring(11)); + if (arg.StartsWith("--3d-size=")) + { + int val = int.Parse(arg.Substring(10)); + chunk3DWidth = chunk3DHeight = chunk3DDepth = val; + } + if (arg.StartsWith("--max-loaded=")) maxLoaded = int.Parse(arg.Substring(13)); + if (arg == "--2d-only") { run3D = false; } + if (arg == "--3d-only") { run2D = false; } + if (arg == "--memory-test") { runMemoryTest = true; } + if (arg == "--stress-test") { runStressTest = true; } + if (arg == "--verbose") { _verboseMode = true; } + } + + PrintHeader(); + + if (run2D) + Benchmark2D(cpuCount, num2DChunks, chunk2DSize, maxLoaded); + + if (run3D) + { + while (true) + { + if (!CanAllocateChunks(num3DChunks, chunk3DWidth, chunk3DHeight, chunk3DDepth, 0.82)) + { + long bytesPerChunk = (long)chunk3DWidth * chunk3DHeight * chunk3DDepth; + long totalBytes = bytesPerChunk * num3DChunks; + long available = GetAvailableMemoryBytes(); + long safeLimit = (long)(available * 0.82); + + Console.ForegroundColor = _errorColor; + Console.WriteLine($"ERROR: Requested 3D chunks would use {totalBytes / (1024 * 1024):N0} MB, but only {safeLimit / (1024 * 1024):N0} MB is safely available."); + Console.WriteLine("Please input new values for 3D chunks and size within your system's memory capacity."); + Console.ResetColor(); + + Console.ForegroundColor = _infoColor; // Using _infoColor here + Console.Write("Enter number of 3D chunks (or blank to exit): "); + Console.ResetColor(); + + var chunksInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(chunksInput)) + { + Console.WriteLine("Exiting benchmark."); + break; + } + if (!int.TryParse(chunksInput, out num3DChunks) || num3DChunks <= 0) + { + Console.WriteLine("Invalid input. Exiting."); + break; + } + + Console.ForegroundColor = _infoColor; // Using _infoColor here + Console.Write("Enter 3D chunk width: "); + Console.ResetColor(); + + var widthInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(widthInput) || !int.TryParse(widthInput, out chunk3DWidth) || chunk3DWidth <= 0) + { + Console.WriteLine("Invalid input. Exiting."); + break; + } + + Console.ForegroundColor = _infoColor; // Using _infoColor here + Console.Write("Enter 3D chunk height: "); + Console.ResetColor(); + + var heightInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(heightInput) || !int.TryParse(heightInput, out chunk3DHeight) || chunk3DHeight <= 0) + { + Console.WriteLine("Invalid input. Exiting."); + break; + } + + Console.ForegroundColor = _infoColor; // Using _infoColor here + Console.Write("Enter 3D chunk depth: "); + Console.ResetColor(); + + var depthInput = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(depthInput) || !int.TryParse(depthInput, out chunk3DDepth) || chunk3DDepth <= 0) + { + Console.WriteLine("Invalid input. Exiting."); + break; + } + Console.WriteLine(); + // Loop will re-check with new values + } + else + { + try + { + Benchmark3D(cpuCount, num3DChunks, chunk3DWidth, chunk3DHeight, chunk3DDepth, maxLoaded); + } + catch (OutOfMemoryException) + { + Console.ForegroundColor = _errorColor; + Console.WriteLine("ERROR: Out of memory during 3D chunk allocation. Reduce chunk size or count."); + Console.ResetColor(); + } + break; + } + } + } + + if (runMemoryTest) + { + RunMemoryTest(cpuCount, chunk2DSize, chunk3DWidth, chunk3DHeight, chunk3DDepth); + } + + if (runStressTest) + { + RunStressTest(cpuCount, chunk2DSize, chunk3DWidth, chunk3DHeight, chunk3DDepth); + } + + PrintSummary(); + PrintDetailedChunkInfo(); + // Save benchmark results to log file + SaveBenchmarkLog(); + + Console.ForegroundColor = _infoColor; // Using _infoColor here + Console.WriteLine("\nDone. Press any key to exit."); + Console.ResetColor(); + Console.ReadKey(); + } + + private static void PrintHeader() + { + Console.ForegroundColor = _headerColor; + Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ ChunkMark: AdvChkSys Benchmark ║"); + Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + + Console.WriteLine($"Library version: {AdvChkSys.AdvChkSys.Version}"); + Console.WriteLine($"CPU threads: {Environment.ProcessorCount}"); + Console.WriteLine($"System memory: {FormatByteSize(AdvChkSys.AdvChkSys.GetMemoryUsage().TotalSystemMemoryBytes)}"); + Console.WriteLine($"Available memory: {FormatByteSize(AdvChkSys.AdvChkSys.GetMemoryUsage().AvailableSystemMemoryBytes)}"); + Console.WriteLine(); + } + + private static void PrintSummary() + { + Console.WriteLine(); + Console.ForegroundColor = _headerColor; + Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ Benchmark Summary ║"); + Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + + if (_results.Count == 0) + { + Console.WriteLine("No benchmark results to display."); + return; + } + + // Standard benchmark results table + var table = new StringBuilder(); + table.AppendLine("┌────────────────┬─────────────┬────────────┬────────────┬────────────┬────────────┐"); + table.AppendLine("│ Test │ Chunks │ Create │ Access │ Unload │ Memory │"); + table.AppendLine("├────────────────┼─────────────┼────────────┼────────────┼────────────┼────────────┤"); + + foreach (var result in _results) + { + string testName = result.TestName.PadRight(14).Substring(0, 14); + string chunkInfo = result.ChunkCount.ToString("N0").PadRight(11).Substring(0, 11); + string createTime = $"{result.CreateTime:F3} s".PadRight(10).Substring(0, 10); + string accessTime = $"{result.AccessTime:F3} s".PadRight(10).Substring(0, 10); + string unloadTime = $"{result.UnloadTime:F3} s".PadRight(10).Substring(0, 10); + string memoryUsed = FormatByteSize(result.MemoryUsed).PadRight(10).Substring(0, 10); + + table.AppendLine($"│ {testName} │ {chunkInfo} │ {createTime} │ {accessTime} │ {unloadTime} │ {memoryUsed} │"); + } + + table.AppendLine("└────────────────┴─────────────┴────────────┴────────────┴────────────┴────────────┘"); + Console.WriteLine(table.ToString()); + + // Calculate and display performance metrics + if (_results.Count > 0) + { + Console.ForegroundColor = _resultColor; + Console.WriteLine("Performance Metrics:"); + Console.ResetColor(); + + var create2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault(); + var create3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault(); + var access2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault(); + var access3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault(); + + if (create2DRate > 0) + Console.WriteLine($"2D Chunk Creation Rate: {create2DRate:N0} chunks/second"); + if (create3DRate > 0) + Console.WriteLine($"3D Chunk Creation Rate: {create3DRate:N0} chunks/second"); + if (access2DRate > 0) + Console.WriteLine($"2D Chunk Access Rate: {access2DRate:N0} chunks/second"); + if (access3DRate > 0) + Console.WriteLine($"3D Chunk Access Rate: {access3DRate:N0} chunks/second"); + } + + // Memory usage summary + Console.ForegroundColor = _memoryColor; + Console.WriteLine("\nMemory Usage Summary:"); + Console.ResetColor(); + + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + Console.WriteLine($"Current Memory Usage: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}"); + Console.WriteLine($"Active Chunks: {memoryReport.ActiveChunkCount:N0}"); + Console.WriteLine($"Available System Memory: {FormatByteSize(memoryReport.AvailableSystemMemoryBytes)}"); + Console.WriteLine($"Memory Usage: {memoryReport.MemoryUsagePercentage:F2}% of total system memory"); + + // System information + Console.ForegroundColor = _headerColor; + Console.WriteLine("\nSystem Information:"); + Console.ResetColor(); + Console.WriteLine($"CPU Threads: {Environment.ProcessorCount}"); + Console.WriteLine($"Total System Memory: {FormatByteSize(memoryReport.TotalSystemMemoryBytes)}"); + Console.WriteLine($"AdvChkSys Version: {AdvChkSys.AdvChkSys.Version}"); + + // Memory efficiency metrics + if (_results.Count > 0) + { + var result2D = _results.FirstOrDefault(r => r.TestName.Contains("2D")); + var result3D = _results.FirstOrDefault(r => r.TestName.Contains("3D")); + + if (result2D != null && result2D.ChunkCount > 0) + { + ulong bytesPerChunk2D = result2D.MemoryUsed / (ulong)result2D.ChunkCount; + Console.WriteLine($"2D Memory Efficiency: {FormatByteSize(bytesPerChunk2D)}/chunk"); + } + + if (result3D != null && result3D.ChunkCount > 0) + { + ulong bytesPerChunk3D = result3D.MemoryUsed / (ulong)result3D.ChunkCount; + Console.WriteLine($"3D Memory Efficiency: {FormatByteSize(bytesPerChunk3D)}/chunk"); + } + } + + // If stress test was run, show its results + var stressResult = _results.FirstOrDefault(r => r.TestName.Contains("Stress")); + if (stressResult != null) + { + Console.ForegroundColor = _headerColor; + Console.WriteLine("\nStress Test Results:"); + Console.ResetColor(); + Console.WriteLine($"Chunks Processed: {stressResult.ChunkCount:N0}"); + Console.WriteLine($"Processing Time: {stressResult.CreateTime:F2} seconds"); + Console.WriteLine($"Processing Rate: {stressResult.ChunkCount / stressResult.CreateTime:N0} chunks/second"); + Console.WriteLine($"Peak Memory Usage: {FormatByteSize(stressResult.MemoryUsed)}"); + } + + + } + + private static void Benchmark2D(int cpuCount, int numChunks, int chunkSize, int maxLoaded) + { + Console.ForegroundColor = _headerColor; + Console.WriteLine($"[2D] {numChunks:N0} chunks of size {chunkSize}x{chunkSize}, maxLoaded={maxLoaded:N0}"); + Console.ResetColor(); + + var constraints = new WorldConstraints + { + MinChunkX = 0, + MaxChunkX = 9999, + MinChunkY = 0, + MaxChunkY = 9999, + MaxLoadedChunks = maxLoaded + }; + var manager = new ChunkManager2D(constraints, maxLoaded); + + // Memory before test + var memoryBefore = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory before: {FormatByteSize(memoryBefore.EstimatedChunkMemoryBytes)}"); + Console.ResetColor(); + } + + // Create and fill + var sw = Stopwatch.StartNew(); + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 1000, y = i / 1000; + var chunk = manager.LoadOrCreateChunk(x, y, chunkSize, chunkSize); + for (int cx = 0; cx < chunkSize; cx++) + for (int cy = 0; cy < chunkSize; cy++) + chunk[cx, cy] = (byte)((cx + cy) % 256); + }); + sw.Stop(); + double createTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Created and filled {numChunks:N0} chunks in {createTime:F3} s"); + + // Memory after creation + var memoryAfterCreate = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory after creation: {FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes)} " + + $"(+{FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})"); + Console.ResetColor(); + } + + // Access (sum all cells) + sw.Restart(); + long total = 0; + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, () => 0L, + (i, state, localSum) => + { + int x = i % 1000, y = i / 1000; + var chunk = manager.GetChunk(x, y); + if (chunk != null) + { + for (int cx = 0; cx < chunkSize; cx++) + for (int cy = 0; cy < chunkSize; cy++) + localSum += chunk[cx, cy]; + } + return localSum; + }, + localSum => Interlocked.Add(ref total, localSum) + ); + sw.Stop(); + double accessTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Accessed {numChunks:N0} chunks in {accessTime:F3} s (sum: {total:N0})"); + + // Unload + sw.Restart(); + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 1000, y = i / 1000; + manager.UnloadChunk(x, y); + }); + sw.Stop(); + double unloadTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Unloaded {numChunks:N0} chunks in {unloadTime:F3} s"); + + // Memory after test + var memoryAfter = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory after unload: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes)} " + + $"(change: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})"); + Console.ResetColor(); + } + + // Store results + _results.Add(new BenchmarkResult + { + TestName = "2D Chunks", + ChunkCount = numChunks, + ChunkSize = $"{chunkSize}x{chunkSize}", + CreateTime = createTime, + AccessTime = accessTime, + UnloadTime = unloadTime, + MemoryUsed = memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes + }); + + // Force GC to clean up + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + private static void Benchmark3D(int cpuCount, int numChunks, int width, int height, int depth, int maxLoaded) + { + Console.ForegroundColor = _headerColor; + Console.WriteLine($"[3D] {numChunks:N0} chunks of size {width}x{height}x{depth}, maxLoaded={maxLoaded:N0}"); + Console.ResetColor(); + + var constraints = new WorldConstraints + { + MinChunkX = 0, + MaxChunkX = 50000, + MinChunkY = 0, + MaxChunkY = 50000, + MinChunkZ = 0, + MaxChunkZ = 50000, + MaxLoadedChunks = maxLoaded + }; + var manager = new ChunkManager3D(constraints, maxLoaded); + + // Memory before test + var memoryBefore = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory before: {FormatByteSize(memoryBefore.EstimatedChunkMemoryBytes)}"); + Console.ResetColor(); + } + + // Create and fill + var sw = Stopwatch.StartNew(); + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 100, y = i / 100 % 100, z = i / 10000; + var chunk = manager.LoadOrCreateChunk(x, y, z, width, height, depth); + for (int cx = 0; cx < width; cx++) + for (int cy = 0; cy < height; cy++) + for (int cz = 0; cz < depth; cz++) + chunk[cx, cy, cz] = (byte)((cx + cy + cz) % 256); + }); + sw.Stop(); + double createTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Created and filled {numChunks:N0} chunks in {createTime:F3} s"); + + // Memory after creation + var memoryAfterCreate = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory after creation: {FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes)} " + + $"(+{FormatByteSize(memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})"); + Console.ResetColor(); + } + + // Access (sum all cells) + sw.Restart(); + long total = 0; + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, () => 0L, + (i, state, localSum) => + { + int x = i % 100, y = i / 100 % 100, z = i / 10000; + var chunk = manager.GetChunk(x, y, z); + if (chunk != null) + { + for (int cx = 0; cx < width; cx++) + for (int cy = 0; cy < height; cy++) + for (int cz = 0; cz < depth; cz++) + localSum += chunk[cx, cy, cz]; + } + return localSum; + }, + localSum => Interlocked.Add(ref total, localSum) + ); + sw.Stop(); + double accessTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Accessed {numChunks:N0} chunks in {accessTime:F3} s (sum: {total:N0})"); + + // Unload + sw.Restart(); + Parallel.For(0, numChunks, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 100, y = i / 100 % 100, z = i / 10000; + manager.UnloadChunk(x, y, z); + }); + sw.Stop(); + double unloadTime = sw.Elapsed.TotalSeconds; + Console.WriteLine($" Unloaded {numChunks:N0} chunks in {unloadTime:F3} s"); + + // Memory after test + var memoryAfter = AdvChkSys.AdvChkSys.GetMemoryUsage(); + if (_verboseMode) + { + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory after unload: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes)} " + + $"(change: {FormatByteSize(memoryAfter.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes)})"); + Console.ResetColor(); + } + + // Store results + _results.Add(new BenchmarkResult + { + TestName = "3D Chunks", + ChunkCount = numChunks, + ChunkSize = $"{width}x{height}x{depth}", + CreateTime = createTime, + AccessTime = accessTime, + UnloadTime = unloadTime, + MemoryUsed = memoryAfterCreate.EstimatedChunkMemoryBytes - memoryBefore.EstimatedChunkMemoryBytes + }); + + // Force GC to clean up + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + private static void RunMemoryTest(int cpuCount, int chunk2DSize, int chunk3DWidth, int chunk3DHeight, int chunk3DDepth) + { + Console.ForegroundColor = _headerColor; + Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ Memory Usage Test ║"); + Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + + // Initial memory state + var initialMemory = AdvChkSys.AdvChkSys.GetMemoryUsage(); + Console.WriteLine($"Initial memory state: {FormatByteSize(initialMemory.EstimatedChunkMemoryBytes)}"); + + // Test 2D memory scaling + Console.ForegroundColor = _headerColor; + Console.WriteLine("\n[2D Memory Scaling Test]"); + Console.ResetColor(); + + var manager2D = new ChunkManager2D(null, 1000000); + var memoryPoints2D = new List<(int chunkCount, ulong memoryUsed)>(); + + for (int i = 1; i <= 10; i++) + { + int chunkCount = i * 1000; + Console.WriteLine($" Loading {chunkCount:N0} 2D chunks..."); + + for (int j = (i - 1) * 1000; j < i * 1000; j++) + { + int x = j % 1000, y = j / 1000; + var chunk = manager2D.LoadOrCreateChunk(x, y, chunk2DSize, chunk2DSize); + // Fill with some data + for (int cx = 0; cx < chunk2DSize; cx++) + for (int cy = 0; cy < chunk2DSize; cy++) + chunk[cx, cy] = (byte)((cx + cy) % 256); + } + + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + memoryPoints2D.Add((chunkCount, memoryReport.EstimatedChunkMemoryBytes)); + Console.WriteLine($" Memory used: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}"); + } + + // Clean up 2D chunks + manager2D = null; + GC.Collect(); + GC.WaitForPendingFinalizers(); + + // Test 3D memory scaling + Console.ForegroundColor = _headerColor; + Console.WriteLine("\n[3D Memory Scaling Test]"); + Console.ResetColor(); + + var manager3D = new ChunkManager3D(null, 1000000); + var memoryPoints3D = new List<(int chunkCount, ulong memoryUsed)>(); + + for (int i = 1; i <= 5; i++) + { + int chunkCount = i * 10; + Console.WriteLine($" Loading {chunkCount:N0} 3D chunks..."); + + for (int j = (i - 1) * 10; j < i * 10; j++) + { + int x = j % 10, y = j / 10 % 10, z = j / 100; + var chunk = manager3D.LoadOrCreateChunk(x, y, z, chunk3DWidth, chunk3DHeight, chunk3DDepth); + // Fill with some data + for (int cx = 0; cx < chunk3DWidth; cx++) + for (int cy = 0; cy < chunk3DHeight; cy++) + for (int cz = 0; cz < chunk3DDepth; cz++) + chunk[cx, cy, cz] = (byte)((cx + cy + cz) % 256); + } + + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + memoryPoints3D.Add((chunkCount, memoryReport.EstimatedChunkMemoryBytes)); + Console.WriteLine($" Memory used: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}"); + } + + // Clean up 3D chunks + manager3D = null; + GC.Collect(); + GC.WaitForPendingFinalizers(); + + // Display memory scaling results + Console.ForegroundColor = _headerColor; + Console.WriteLine("\n[Memory Scaling Results]"); + Console.ResetColor(); + + Console.WriteLine("2D Memory Scaling:"); + for (int i = 0; i < memoryPoints2D.Count; i++) + { + var point = memoryPoints2D[i]; + ulong bytesPerChunk = i > 0 + ? (point.memoryUsed - memoryPoints2D[i - 1].memoryUsed) / (ulong)(point.chunkCount - memoryPoints2D[i - 1].chunkCount) + : point.memoryUsed / (ulong)point.chunkCount; + + Console.WriteLine($" {point.chunkCount:N0} chunks: {FormatByteSize(point.memoryUsed)} " + + $"(~{FormatByteSize(bytesPerChunk)}/chunk)"); + } + + Console.WriteLine("\n3D Memory Scaling:"); + for (int i = 0; i < memoryPoints3D.Count; i++) + { + var point = memoryPoints3D[i]; + ulong bytesPerChunk = i > 0 + ? (point.memoryUsed - memoryPoints3D[i - 1].memoryUsed) / (ulong)(point.chunkCount - memoryPoints3D[i - 1].chunkCount) + : point.memoryUsed / (ulong)point.chunkCount; + + Console.WriteLine($" {point.chunkCount:N0} chunks: {FormatByteSize(point.memoryUsed)} " + + $"(~{FormatByteSize(bytesPerChunk)}/chunk)"); + } + + // Final memory state + var finalMemory = AdvChkSys.AdvChkSys.GetMemoryUsage(); + Console.WriteLine($"\nFinal memory state: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes)}"); + Console.WriteLine($"Memory change: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes - initialMemory.EstimatedChunkMemoryBytes)}"); + } + + private static void RunStressTest(int cpuCount, int chunk2DSize, int chunk3DWidth, int chunk3DHeight, int chunk3DDepth) + { + Console.ForegroundColor = _headerColor; + Console.WriteLine("╔═══════════════════════════════════════════════════════════════╗"); + Console.WriteLine("║ Stress Test ║"); + Console.WriteLine("╚═══════════════════════════════════════════════════════════════╝"); + Console.ResetColor(); + + // Initial memory state + var initialMemory = AdvChkSys.AdvChkSys.GetMemoryUsage(); + + // Create managers with small cache size to force evictions + var manager2D = new ChunkManager2D(null, 100); + var manager3D = new ChunkManager3D(null, 50); + + Console.WriteLine("Running load/unload stress test (rapid chunk cycling)..."); + + var sw = Stopwatch.StartNew(); + int iterations = 5; + int chunksPerIteration = 500; + + for (int iter = 0; iter < iterations; iter++) + { + Console.WriteLine($" Iteration {iter + 1}/{iterations}..."); + + // 2D stress + Parallel.For(0, chunksPerIteration, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 100, y = i / 100; + // Load chunk + var chunk = manager2D.LoadOrCreateChunk(x, y, chunk2DSize, chunk2DSize); + // Fill with data + for (int cx = 0; cx < chunk2DSize; cx++) + for (int cy = 0; cy < chunk2DSize; cy++) + chunk[cx, cy] = (byte)((cx + cy + i) % 256); + + // Access chunk + long sum = 0; + for (int cx = 0; cx < chunk2DSize; cx++) + for (int cy = 0; cy < chunk2DSize; cy++) + sum += chunk[cx, cy]; + + // Force some evictions by loading other chunks + for (int j = 0; j < 3; j++) + { + int otherX = (i + j * 200) % 1000; + int otherY = (i + j * 200) / 1000; + var otherChunk = manager2D.LoadOrCreateChunk(otherX, otherY, chunk2DSize, chunk2DSize); + otherChunk[0, 0] = (byte)(i + j); + } + }); + + // 3D stress + Parallel.For(0, chunksPerIteration / 10, new ParallelOptions { MaxDegreeOfParallelism = cpuCount }, i => + { + int x = i % 10, y = i / 10 % 10, z = i / 100; + // Load chunk + var chunk = manager3D.LoadOrCreateChunk(x, y, z, chunk3DWidth, chunk3DHeight, chunk3DDepth); + // Fill with data (just a portion to save time) + for (int cx = 0; cx < chunk3DWidth; cx++) + for (int cy = 0; cy < chunk3DHeight / 4; cy++) + for (int cz = 0; cz < chunk3DDepth; cz++) + chunk[cx, cy, cz] = (byte)((cx + cy + cz + i) % 256); + + // Access chunk + long sum = 0; + for (int cx = 0; cx < chunk3DWidth; cx++) + for (int cy = 0; cy < chunk3DHeight / 4; cy++) + for (int cz = 0; cz < chunk3DDepth; cz++) + sum += chunk[cx, cy, cz]; + + // Force some evictions by loading other chunks + for (int j = 0; j < 2; j++) + { + int otherX = (i + j * 20) % 30; + int otherY = (i + j * 20) / 30 % 30; + int otherZ = (i + j * 20) / 900; + var otherChunk = manager3D.LoadOrCreateChunk(otherX, otherY, otherZ, chunk3DWidth, chunk3DHeight, chunk3DDepth); + otherChunk[0, 0, 0] = (byte)(i + j); + } + }); + + // Check memory after each iteration + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + Console.ForegroundColor = _memoryColor; + Console.WriteLine($" Memory: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)} " + + $"({memoryReport.MemoryUsagePercentage:F2}% of system memory)"); + Console.ResetColor(); + + // Force GC between iterations + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + sw.Stop(); + + Console.WriteLine($"Stress test completed in {sw.Elapsed.TotalSeconds:F2} seconds"); + + // Final memory state + var finalMemory = AdvChkSys.AdvChkSys.GetMemoryUsage(); + Console.WriteLine($"Final memory state: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes)}"); + Console.WriteLine($"Memory change: {FormatByteSize(finalMemory.EstimatedChunkMemoryBytes - initialMemory.EstimatedChunkMemoryBytes)}"); + + // Clean up + manager2D = null; + manager3D = null; + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + + private static bool CanAllocateChunks(int numChunks, int width, int height, int depth, double safetyFactor) + { + long bytesPerChunk = (long)width * height * depth; + long totalBytes = bytesPerChunk * numChunks; + long available = GetAvailableMemoryBytes(); + long safeLimit = (long)(available * safetyFactor); + + return totalBytes <= safeLimit; + } + + private static long GetAvailableMemoryBytes() + { + return (long)AdvChkSys.AdvChkSys.GetMemoryUsage().AvailableSystemMemoryBytes; + } + + private static string FormatByteSize(ulong bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double formattedSize = bytes; + int order = 0; + + while (formattedSize >= 1024 && order < sizes.Length - 1) + { + order++; + formattedSize /= 1024; + } + + return $"{formattedSize:F2} {sizes[order]}"; + } + + private static void SaveBenchmarkLog() + { + // Create logs directory if it doesn't exist + string logsDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs"); + Directory.CreateDirectory(logsDirectory); + + // Generate a unique filename with timestamp + string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); + string logFileName = $"ChunkMark_{timestamp}.log"; + string logFilePath = Path.Combine(logsDirectory, logFileName); + + // Create a string builder to hold the log content + var logContent = new StringBuilder(); + + // Add header + logContent.AppendLine("================================================================="); + logContent.AppendLine($"ChunkMark Benchmark Results - {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}"); + logContent.AppendLine("================================================================="); + logContent.AppendLine(); + + // Add system information + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + logContent.AppendLine("System Information:"); + logContent.AppendLine($"CPU Threads: {Environment.ProcessorCount}"); + logContent.AppendLine($"Total System Memory: {FormatByteSize(memoryReport.TotalSystemMemoryBytes)}"); + logContent.AppendLine($"Available System Memory: {FormatByteSize(memoryReport.AvailableSystemMemoryBytes)}"); + logContent.AppendLine($"AdvChkSys Version: {AdvChkSys.AdvChkSys.Version}"); + logContent.AppendLine(); + + // Add benchmark results + logContent.AppendLine("Benchmark Results:"); + logContent.AppendLine("------------------------------------------------------------------"); + if (_results.Count == 0) + { + logContent.AppendLine("No benchmark results to display."); + } + else + { + // Header row + logContent.AppendLine( + $"{"Test",-15} | {"Chunks",-10} | {"Size",-12} | {"Create (s)",-10} | {"Access (s)",-10} | {"Unload (s)",-10} | {"Memory",-12}"); + logContent.AppendLine(new string('-', 90)); + + // Data rows + foreach (var result in _results) + { + logContent.AppendLine( + $"{result.TestName,-15} | {result.ChunkCount,-10:N0} | {result.ChunkSize,-12} | " + + $"{result.CreateTime,-10:F3} | {result.AccessTime,-10:F3} | {result.UnloadTime,-10:F3} | " + + $"{FormatByteSize(result.MemoryUsed),-12}"); + } + } + logContent.AppendLine(); + + // Add performance metrics + logContent.AppendLine("Performance Metrics:"); + logContent.AppendLine("------------------------------------------------------------------"); + if (_results.Count > 0) + { + var create2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault(); + var create3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.CreateTime).FirstOrDefault(); + var access2DRate = _results.Where(r => r.TestName.Contains("2D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault(); + var access3DRate = _results.Where(r => r.TestName.Contains("3D")).Select(r => r.ChunkCount / r.AccessTime).FirstOrDefault(); + + if (create2DRate > 0) + logContent.AppendLine($"2D Chunk Creation Rate: {create2DRate:N0} chunks/second"); + if (create3DRate > 0) + logContent.AppendLine($"3D Chunk Creation Rate: {create3DRate:N0} chunks/second"); + if (access2DRate > 0) + logContent.AppendLine($"2D Chunk Access Rate: {access2DRate:N0} chunks/second"); + if (access3DRate > 0) + logContent.AppendLine($"3D Chunk Access Rate: {access3DRate:N0} chunks/second"); + } + logContent.AppendLine(); + + // Add memory usage summary + logContent.AppendLine("Memory Usage Summary:"); + logContent.AppendLine("------------------------------------------------------------------"); + logContent.AppendLine($"Current Memory Usage: {FormatByteSize(memoryReport.EstimatedChunkMemoryBytes)}"); + logContent.AppendLine($"Active Chunks: {memoryReport.ActiveChunkCount:N0}"); + logContent.AppendLine($"Memory Usage: {memoryReport.MemoryUsagePercentage:F2}% of total system memory"); + + // Memory efficiency metrics + if (_results.Count > 0) + { + var result2D = _results.FirstOrDefault(r => r.TestName.Contains("2D")); + var result3D = _results.FirstOrDefault(r => r.TestName.Contains("3D")); + + if (result2D != null && result2D.ChunkCount > 0) + { + ulong bytesPerChunk2D = result2D.MemoryUsed / (ulong)result2D.ChunkCount; + logContent.AppendLine($"2D Memory Efficiency: {FormatByteSize(bytesPerChunk2D)}/chunk"); + } + + if (result3D != null && result3D.ChunkCount > 0) + { + ulong bytesPerChunk3D = result3D.MemoryUsed / (ulong)result3D.ChunkCount; + logContent.AppendLine($"3D Memory Efficiency: {FormatByteSize(bytesPerChunk3D)}/chunk"); + } + } + // Detailed Chunk Info + logContent.AppendLine(); + logContent.AppendLine("Detailed Chunk Information:"); + logContent.AppendLine("------------------------------------------------------------------"); + logContent.AppendLine($"Active Chunks (reported by ChunkResourceManager): {memoryReport.ActiveChunkCount:N0}"); + + // Get counts from results + int total2DChunks = _results.Where(r => r.TestName.Contains("2D")).Sum(r => r.ChunkCount); + int total3DChunks = _results.Where(r => r.TestName.Contains("3D")).Sum(r => r.ChunkCount); + int totalChunks = total2DChunks + total3DChunks; + + logContent.AppendLine($"Total 2D Chunks Created: {total2DChunks:N0}"); + logContent.AppendLine($"Total 3D Chunks Created: {total3DChunks:N0}"); + logContent.AppendLine($"Total Chunks Created: {totalChunks:N0}"); + + if (memoryReport.ActiveChunkCount != totalChunks) + { + logContent.AppendLine(); + logContent.AppendLine("Possible reasons for the difference:"); + logContent.AppendLine("1. Chunk eviction due to LRU cache capacity limits"); + logContent.AppendLine("2. Chunks unloaded during benchmark cleanup"); + logContent.AppendLine("3. All-air chunks using flyweight pattern (counted once)"); + logContent.AppendLine("4. Garbage collection removing unreferenced chunks"); + } + + + // Write the log to file + File.WriteAllText(logFilePath, logContent.ToString()); + + Console.ForegroundColor = _infoColor; + Console.WriteLine($"\nBenchmark results saved to: {logFilePath}"); + Console.ResetColor(); + } + + private static void PrintDetailedChunkInfo() + { + Console.ForegroundColor = _headerColor; + Console.WriteLine("\nDetailed Chunk Information:"); + Console.ResetColor(); + + var memoryReport = AdvChkSys.AdvChkSys.GetMemoryUsage(); + + Console.WriteLine($"Active Chunks (reported by ChunkResourceManager): {memoryReport.ActiveChunkCount:N0}"); + + // Get counts from results + int total2DChunks = _results.Where(r => r.TestName.Contains("2D")).Sum(r => r.ChunkCount); + int total3DChunks = _results.Where(r => r.TestName.Contains("3D")).Sum(r => r.ChunkCount); + int totalChunks = total2DChunks + total3DChunks; + + Console.WriteLine($"Total 2D Chunks Created: {total2DChunks:N0}"); + Console.WriteLine($"Total 3D Chunks Created: {total3DChunks:N0}"); + Console.WriteLine($"Total Chunks Created: {totalChunks:N0}"); + + if (memoryReport.ActiveChunkCount != totalChunks) + { + Console.ForegroundColor = _infoColor; + Console.WriteLine("\nPossible reasons for the difference:"); + Console.WriteLine("1. Chunk eviction due to LRU cache capacity limits"); + Console.WriteLine("2. Chunks unloaded during benchmark cleanup"); + Console.WriteLine("3. All-air chunks using flyweight pattern (counted once)"); + Console.WriteLine("4. Garbage collection removing unreferenced chunks"); + Console.ResetColor(); + } + } + } + + public class BenchmarkResult + { + public string TestName { get; set; } = ""; + public int ChunkCount { get; set; } + public string ChunkSize { get; set; } = ""; + public double CreateTime { get; set; } + public double AccessTime { get; set; } + public double UnloadTime { get; set; } + public ulong MemoryUsed { get; set; } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs b/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs new file mode 100644 index 0000000..868f0f9 --- /dev/null +++ b/advchksys/src/AdvChkSys.Benchmarks/MemoryReporter.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using AdvChkSys.Diagnostics; + +namespace ChunkMark +{ + /// + /// Provides detailed memory reporting and visualization for benchmarks. + /// + public class MemoryReporter + { + private readonly List _snapshots = new List(); + private readonly Stopwatch _timer = new Stopwatch(); + private readonly string _reportName; + + public MemoryReporter(string reportName) + { + _reportName = reportName; + _timer.Start(); + } + + /// + /// Takes a memory snapshot with the given label. + /// + public void TakeSnapshot(string label) + { + var report = AdvChkSys.AdvChkSys.GetMemoryUsage(); + _snapshots.Add(new MemorySnapshot + { + Label = label, + TimeSeconds = _timer.Elapsed.TotalSeconds, + Report = report + }); + } + + /// + /// Generates a detailed memory report as a string. + /// + public string GenerateReport() + { + if (_snapshots.Count == 0) + return "No memory snapshots recorded."; + + var sb = new StringBuilder(); + sb.AppendLine($"Memory Report: {_reportName}"); + sb.AppendLine("=".PadRight(50, '=')); + sb.AppendLine(); + + sb.AppendLine("Snapshots:"); + sb.AppendLine("┌─────────────────┬──────────┬─────────────────┬─────────────┬───────────┐"); + sb.AppendLine("│ Label │ Time (s) │ Chunk Memory │ Active │ Usage % │"); + sb.AppendLine("├─────────────────┼──────────┼─────────────────┼─────────────┼───────────┤"); + + foreach (var snapshot in _snapshots) + { + string label = snapshot.Label.PadRight(15).Substring(0, 15); + string time = $"{snapshot.TimeSeconds:F2}".PadRight(8).Substring(0, 8); + string memory = FormatByteSize(snapshot.Report.EstimatedChunkMemoryBytes).PadRight(15).Substring(0, 15); + string active = $"{snapshot.Report.ActiveChunkCount:N0}".PadRight(11).Substring(0, 11); + string usage = $"{snapshot.Report.MemoryUsagePercentage:F2}%".PadRight(9).Substring(0, 9); + + sb.AppendLine($"│ {label} │ {time} │ {memory} │ {active} │ {usage} │"); + } + sb.AppendLine("└─────────────────┴──────────┴─────────────────┴─────────────┴───────────┘"); + sb.AppendLine(); + + // Memory change analysis + if (_snapshots.Count >= 2) + { + var first = _snapshots.First(); + var last = _snapshots.Last(); + var maxMem = _snapshots.Max(s => s.Report.EstimatedChunkMemoryBytes); + var minMem = _snapshots.Min(s => s.Report.EstimatedChunkMemoryBytes); + + sb.AppendLine("Memory Analysis:"); + sb.AppendLine($" Initial Memory: {FormatByteSize(first.Report.EstimatedChunkMemoryBytes)}"); + sb.AppendLine($" Final Memory: {FormatByteSize(last.Report.EstimatedChunkMemoryBytes)}"); + sb.AppendLine($" Net Change: {FormatByteSize(last.Report.EstimatedChunkMemoryBytes - first.Report.EstimatedChunkMemoryBytes)}"); + sb.AppendLine($" Peak Memory: {FormatByteSize(maxMem)}"); + sb.AppendLine($" Minimum Memory: {FormatByteSize(minMem)}"); + sb.AppendLine($" Memory Volatility: {FormatByteSize(maxMem - minMem)}"); + } + + return sb.ToString(); + } + + /// + /// Saves the memory report to a file. + /// + public void SaveReportToFile(string filePath) + { + string report = GenerateReport(); + File.WriteAllText(filePath, report); + } + + /// + /// Formats a byte size into a human-readable string (KB, MB, GB). + /// + private static string FormatByteSize(ulong bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double formattedSize = bytes; + int order = 0; + + while (formattedSize >= 1024 && order < sizes.Length - 1) + { + order++; + formattedSize /= 1024; + } + + return $"{formattedSize:F2} {sizes[order]}"; + } + + private class MemorySnapshot + { + public string Label { get; set; } = ""; + public double TimeSeconds { get; set; } + public MemoryUsageReport Report { get; set; } = null!; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys.Benchmarks/README.md b/advchksys/src/AdvChkSys.Benchmarks/README.md new file mode 100644 index 0000000..6b7e861 --- /dev/null +++ b/advchksys/src/AdvChkSys.Benchmarks/README.md @@ -0,0 +1,123 @@ +# ChunkMark - AdvChkSys Benchmarking Tool + +ChunkMark is a comprehensive benchmarking and diagnostic tool for the AdvChkSys chunk management library. It provides detailed performance metrics and memory usage analysis to help developers optimize their chunk-based applications. + +## Features + +- **Performance Benchmarking**: Measures chunk creation, access, and unloading speeds +- **Memory Analysis**: Tracks memory consumption patterns during chunk operations +- **Stress Testing**: Simulates high-load scenarios with rapid chunk cycling +- **Memory Scaling Tests**: Shows how memory usage scales with increasing chunk counts +- **Detailed Reporting**: Provides formatted tables and summaries of benchmark results + +## Usage + +``` +ChunkMark.exe [options] +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| `--cpus=N` | Set the number of CPU threads to use (default: all available) | +| `--2d-chunks=N` | Number of 2D chunks to benchmark (default: 10000) | +| `--2d-size=N` | Size of 2D chunks (NxN) (default: 32) | +| `--3d-chunks=N` | Number of 3D chunks to benchmark (default: 500) | +| `--3d-width=N` | Width of 3D chunks (default: 16) | +| `--3d-height=N` | Height of 3D chunks (default: 16) | +| `--3d-depth=N` | Depth of 3D chunks (default: 16) | +| `--3d-size=N` | Set all 3D dimensions to N (overrides individual settings) | +| `--max-loaded=N` | Maximum chunks to keep in memory (default: 100000) | +| `--2d-only` | Run only 2D benchmarks | +| `--3d-only` | Run only 3D benchmarks | +| `--memory-test` | Run memory scaling tests | +| `--stress-test` | Run chunk cycling stress tests | +| `--verbose` | Show detailed memory information during tests | + +### Examples + +Basic benchmark with default settings: +``` +ChunkMark.exe +``` + +Run only 2D benchmarks with 50,000 chunks of size 64x64: +``` +ChunkMark.exe --2d-only --2d-chunks=50000 --2d-size=64 +``` + +Run 3D benchmarks with custom dimensions: +``` +ChunkMark.exe --3d-only --3d-chunks=100 --3d-width=32 --3d-height=128 --3d-depth=32 +``` + +Run memory scaling tests with verbose output: +``` +ChunkMark.exe --memory-test --verbose +``` + +## Benchmark Types + +### Standard Benchmarks + +The standard benchmarks measure three key operations: + +1. **Creation**: Time to create and fill chunks with data +2. **Access**: Time to read all data from chunks +3. **Unload**: Time to unload chunks from memory + +These benchmarks are run for both 2D and 3D chunks (unless limited by command line options). + +### Memory Scaling Test + +This test incrementally loads more chunks and measures memory consumption at each step. It helps visualize how memory usage scales with chunk count and calculates the average memory per chunk. + +### Stress Test + +The stress test rapidly cycles chunks in and out of the cache to test the LRU eviction mechanism and memory stability under high load. It's useful for identifying memory leaks or performance degradation during extended use. + +## Output + +ChunkMark provides formatted output with: + +- Benchmark results in tabular format +- Memory usage statistics +- Performance metrics (chunks/second) +- Summary of all tests + +In verbose mode, it also shows detailed memory snapshots before and after each operation. + +## Memory Safety + +ChunkMark includes memory safety checks to prevent out-of-memory errors. If a benchmark would require more memory than is safely available, ChunkMark will prompt for alternative parameters or skip the test. + +## Interpreting Results + +- **Creation Time**: Lower is better. Measures how quickly chunks can be created and filled. +- **Access Time**: Lower is better. Measures how quickly data can be read from chunks. +- **Unload Time**: Lower is better. Measures how quickly chunks can be removed from memory. +- **Memory Usage**: Lower is better for a given number of chunks. +- **Chunks/Second**: Higher is better. Derived metric showing throughput. + +## System Requirements +- Windows, Linux, or macOS operating system +- .NET 5.0 or higher +- Sufficient memory for the requested benchmark parameters (e.g., 3D benchmark with 1024 chunks of size 384×384×384 would require approximately 54-60 GB of RAM) (e.g., 3D benchmark with 1000 chunks of size 32×32×32 would require approximately 125 MB of RAM) +- Math for 1024 chunks at a size of 384x384x384. + +### Memory Calculation +For 1024 chunks at a size of 384×384×384: +```python +# Calculate cells per chunk +cells_per_chunk = 384 × 384 × 384 = 56,623,104 cells + +# Calculate total bytes +total_bytes = cells_per_chunk × 1024 chunks = 57,972,964,416 bytes + +# Convert to GB +total_GB = total_bytes / 1024 / 1024 / 1024 = 54.2 GB + +# Add 5-20% overhead for metadata, references, and system overhead +final_estimate = 54.2 GB × (1.05 to 1.20) = 56.9 to 65.0 GB +``` \ No newline at end of file diff --git a/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py b/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py new file mode 100644 index 0000000..e5057de --- /dev/null +++ b/advchksys/src/AdvChkSys.Benchmarks/benchmark_advchksys.py @@ -0,0 +1,164 @@ +# noqa: E501 +import time +import advchksys +from concurrent.futures import ThreadPoolExecutor + + +def parallel_benchmark_2d(manager, num_chunks, chunk_size, max_workers=None): + print( + f"\n[2D] Parallel Benchmark: {num_chunks} chunks of size {chunk_size}x{chunk_size} (max_workers={max_workers})" + ) + t0 = time.perf_counter() + + def create_and_fill(i): + x, y = i % 100, i // 100 + chunk = manager.LoadOrCreateChunk(x, y, chunk_size, chunk_size) + for cx in range(chunk_size): + for cy in range(chunk_size): + chunk[cx, cy] = (cx + cy) % 256 + + # Parallel chunk creation and filling + with ThreadPoolExecutor(max_workers=max_workers) as executor: + list(executor.map(create_and_fill, range(num_chunks))) + + t1 = time.perf_counter() + print( + f" Created and filled {num_chunks} chunks in {t1-t0:.3f} seconds (parallel)" + ) + + # Parallel chunk access (sum all cells) + t2 = time.perf_counter() + + def sum_chunk(i): + x, y = i % 100, i // 100 + chunk = manager.GetChunk(x, y) + subtotal = 0 + if chunk is not None: + for cx in range(chunk_size): + for cy in range(chunk_size): + subtotal += chunk[cx, cy] + return subtotal + + total = 0 + with ThreadPoolExecutor(max_workers=max_workers) as executor: + for subtotal in executor.map(sum_chunk, range(num_chunks)): + total += subtotal + + t3 = time.perf_counter() + print( + f" Accessed {num_chunks} chunks in {t3-t2:.3f} seconds (sum: {total}, parallel)" + ) + + # Parallel chunk unload + t4 = time.perf_counter() + + def unload_chunk(i): + x, y = i % 100, i // 100 + manager.UnloadChunk(x, y) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + list(executor.map(unload_chunk, range(num_chunks))) + + t5 = time.perf_counter() + print(f" Unloaded {num_chunks} chunks in {t5-t4:.3f} seconds (parallel)") + + +def parallel_benchmark_3d( + manager, + num_chunks, + chunk_size_x, + chunk_size_y, + chunk_size_z, + max_workers=None, +): + print( + f"\n[3D] Parallel Benchmark: {num_chunks} chunks of size {chunk_size_x}x{chunk_size_y}x{chunk_size_z} (max_workers={max_workers})" + ) + t0 = time.perf_counter() + + def create_and_fill(i): + x, y, z = i % 10, (i // 10) % 10, i // 100 + chunk = manager.LoadOrCreateChunk( + x, y, z, chunk_size_x, chunk_size_y, chunk_size_z + ) + for cx in range(chunk_size_x): + for cy in range(chunk_size_y): + for cz in range(chunk_size_z): + chunk[cx, cy, cz] = (cx + cy + cz) % 256 + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + list(executor.map(create_and_fill, range(num_chunks))) + + t1 = time.perf_counter() + print( + f" Created and filled {num_chunks} chunks in {t1-t0:.3f} seconds (parallel)" + ) + + # Parallel chunk access (sum all cells) + t2 = time.perf_counter() + + def sum_chunk(i): + x, y, z = i % 10, (i // 10) % 10, i // 100 + chunk = manager.GetChunk(x, y, z) + subtotal = 0 + if chunk is not None: + for cx in range(chunk_size_x): + for cy in range(chunk_size_y): + for cz in range(chunk_size_z): + subtotal += chunk[cx, cy, cz] + return subtotal + + total = 0 + with ThreadPoolExecutor(max_workers=max_workers) as executor: + for subtotal in executor.map(sum_chunk, range(num_chunks)): + total += subtotal + + t3 = time.perf_counter() + print( + f" Accessed {num_chunks} chunks in {t3-t2:.3f} seconds (sum: {total}, parallel)" + ) + + # Parallel chunk unload + t4 = time.perf_counter() + + def unload_chunk(i): + x, y, z = i % 10, (i // 10) % 10, i // 100 + manager.UnloadChunk(x, y, z) + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + list(executor.map(unload_chunk, range(num_chunks))) + + t5 = time.perf_counter() + print(f" Unloaded {num_chunks} chunks in {t5-t4:.3f} seconds (parallel)") + + +def main(): + print("AdvChkSys Python Parallel Benchmark") + print("Library version:", advchksys.get_version()) + + # 2D Parallel Benchmark + constraints2d = advchksys.create_constraints( + min_x=0, max_x=99, min_y=0, max_y=200, max_loaded=10000000 + ) + manager2d = advchksys.create_2d_manager(constraints2d) + parallel_benchmark_2d( + manager2d, num_chunks=20000, chunk_size=32, max_workers=4 + ) + + # 3D Parallel Benchmark + constraints3d = advchksys.create_constraints( + min_x=0, max_x=9, min_y=0, max_y=9, max_loaded=10000 + ) + manager3d = advchksys.create_3d_manager(constraints3d) + parallel_benchmark_3d( + manager3d, + num_chunks=100, + chunk_size_x=16, + chunk_size_y=16, + chunk_size_z=32, + max_workers=4, + ) + + +if __name__ == "__main__": + main() diff --git a/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj b/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj new file mode 100644 index 0000000..1b056cf --- /dev/null +++ b/advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs b/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs new file mode 100644 index 0000000..590b30a --- /dev/null +++ b/advchksys/src/AdvChkSys.Tests/ChunkManagerTests.cs @@ -0,0 +1,88 @@ +using AdvChkSys.Chunk; +using AdvChkSys.Manager; +using AdvChkSys.Serialization; + +namespace AdvChkSys.Tests; + +public sealed class ChunkManagerTests +{ + [Fact] + public void AllAirChunksDoNotAliasAcrossCoordinates() + { + var manager = new ChunkManager2D(capacity: 8, chunkWidth: 8, chunkHeight: 8); + manager.SetAirCheckDelegate(static (_, _, _, _) => true); + + var first = manager.LoadOrCreateChunk(0, 0, 8, 8); + var second = manager.LoadOrCreateChunk(1, 0, 8, 8); + + Assert.NotSame(first, second); + Assert.Equal(0, first.X); + Assert.Equal(0, first.Y); + Assert.Equal(1, second.X); + Assert.Equal(0, second.Y); + Assert.True(first.IsAllAir); + Assert.True(second.IsAllAir); + } + + [Fact] + public void EvictionReloadCreatesFreshChunkInstance() + { + var manager = new ChunkManager2D(capacity: 1, chunkWidth: 4, chunkHeight: 4); + var original = manager.LoadOrCreateChunk(0, 0, 4, 4); + original[1, 1] = 42; + + _ = manager.LoadOrCreateChunk(1, 0, 4, 4); + var reloaded = manager.LoadOrCreateChunk(0, 0, 4, 4); + + Assert.NotSame(original, reloaded); + Assert.Equal((byte)0, reloaded[1, 1]); + } + + [Fact] + public void SerializeDeserialize2DRoundTripsDataAndMetadata() + { + var chunk = new Chunk2D(2, 3, 4, 4); + chunk.Metadata["tag"] = "test"; + chunk[1, 2] = 99; + + var bytes = ChunkSerializer.Serialize2D(chunk); + var clone = ChunkSerializer.Deserialize2D(bytes); + + Assert.Equal(2, clone.X); + Assert.Equal(3, clone.Y); + Assert.Equal(4, clone.Width); + Assert.Equal(4, clone.Height); + Assert.Equal("test", clone.Metadata["tag"]); + Assert.Equal(99, clone[1, 2]); + } + + [Fact] + public void SerializeDeserialize3DRoundTripsDataAndMetadata() + { + var chunk = new Chunk3D(1, 2, 3, 4, 4, 4); + chunk.Metadata["biome"] = "ash"; + chunk[1, 1, 1] = 7; + + var bytes = ChunkSerializer.Serialize3D(chunk); + var clone = ChunkSerializer.Deserialize3D(bytes); + + Assert.Equal(1, clone.X); + Assert.Equal(2, clone.Y); + Assert.Equal(3, clone.Z); + Assert.Equal("ash", clone.Metadata["biome"]); + Assert.Equal((short)7, clone[1, 1, 1]); + } + + [Fact] + public async Task ConcurrentLoadRequestsReturnSameChunkInstance() + { + var manager = new ChunkManager2D(capacity: 16, chunkWidth: 8, chunkHeight: 8); + var tasks = Enumerable.Range(0, 16) + .Select(_ => manager.LoadOrCreateChunkAsync(5, 7, 8, 8)) + .ToArray(); + + var chunks = await Task.WhenAll(tasks); + + Assert.All(chunks, chunk => Assert.Same(chunks[0], chunk)); + } +} diff --git a/advchksys/src/AdvChkSys/AdvChkSys.cs b/advchksys/src/AdvChkSys/AdvChkSys.cs new file mode 100644 index 0000000..9774819 --- /dev/null +++ b/advchksys/src/AdvChkSys/AdvChkSys.cs @@ -0,0 +1,162 @@ +#nullable enable +using AdvChkSys.Constraints; +using AdvChkSys.Manager; +using AdvChkSys.Chunk; +using AdvChkSys.Resources; +using AdvChkSys.Diagnostics; +using AdvChkSys.Threading; +using AdvChkSys.Spatial; +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AdvChkSys +{ + /// + /// Entry point and static facade for the AdvChkSys library. + /// Provides version info, helpers for creating managers and constraints, and a basic self-test. + /// + public static class AdvChkSys + { + /// + /// The current version of the AdvChkSys library. + /// + public static string Version => "0.2.0"; + + /// + /// Creates a new WorldConstraints object. + /// + public static WorldConstraints CreateDefaultConstraints() => new(); + + /// + /// Creates a new 2D chunk manager with optional constraints. + /// + public static ChunkManager2D Create2DManager(WorldConstraints? constraints = null) => + new(constraints); + + /// + /// Creates a new 3D chunk manager with optional constraints. + /// + public static ChunkManager3D Create3DManager(WorldConstraints? constraints = null) => + new(constraints); + + /// + /// Gets a detailed report of the current memory usage by the chunk system. + /// + /// A report containing memory usage statistics + public static MemoryUsageReport GetMemoryUsage() => MemoryUsageReporter.GetMemoryUsage(); + + /// + /// Logs the current memory usage to the provided logging action. + /// + /// Action that will receive the log messages + public static void LogMemoryUsage(Action logAction) => + MemoryUsageReporter.LogMemoryUsage(logAction); + + /// + /// Gets the threading manager for advanced threading operations. + /// + public static ChunkThreadingManager ThreadingManager => ChunkThreadingManager.Instance; + + /// + /// Runs a batch of chunk operations in parallel. + /// + public static Task RunParallelChunkOperationsAsync(IEnumerable operations, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + return ChunkTaskScheduler.RunBatchParallelAsync(operations, maxDegreeOfParallelism, cancellationToken); + } + + /// + /// Creates a spatial index for 2D chunks. + /// + public static SpatialChunkIndex> CreateSpatialIndex2D() => + new(); + + /// + /// Creates a spatial index for 3D chunks. + /// + public static SpatialChunkIndex> CreateSpatialIndex3D() => + new(); + + /// + /// Configures the threading system for high throughput. + /// + public static void ConfigureForHighThroughput() => + ChunkThreadingConfiguration.ConfigureForHighThroughput(); + + /// + /// Configures the threading system for low latency. + /// + public static void ConfigureForLowLatency() => + ChunkThreadingConfiguration.ConfigureForLowLatency(); + + /// + /// Configures the threading system for memory efficiency. + /// + public static void ConfigureForMemoryEfficiency() => + ChunkThreadingConfiguration.ConfigureForMemoryEfficiency(); + + /// + /// Performs a basic self-test of the AdvChkSys core functionality. + /// Returns true if all core systems are operational. + /// + public static bool SelfTest() + { + // Create constraints + var constraints = new WorldConstraints + { + MinChunkX = 0, + MinChunkY = 0, + MaxChunkX = 2, + MaxChunkY = 2, + MaxLoadedChunks = 4 + }; + + // Create managers + var manager2D = new ChunkManager2D(constraints); + var manager3D = new ChunkManager3D(constraints); + + // Test 2D chunk creation + var chunk2D = manager2D.LoadOrCreateChunk(1, 1, 8, 8); + chunk2D[0, 0] = 42; + + // Test 3D chunk creation + var chunk3D = manager3D.LoadOrCreateChunk(1, 1, 1, 4, 4, 4); + chunk3D[0, 0, 0] = 99; + + // Check resource manager tracking + bool resourceTracking2D = ChunkResourceManager.AllocatedChunkCount >= 1; + bool resourceTracking3D = ChunkResourceManager.AllocatedChunkCount >= 2; + + // Check constraints enforcement + bool constraintsWork = constraints.IsWithinBounds(1, 1) && constraints.IsWithinChunkLimit(2); + + // Test spatial indexing + var spatialIndex2D = CreateSpatialIndex2D(); + spatialIndex2D.AddChunk(chunk2D); + var chunksInRegion = spatialIndex2D.FindChunksInRegion(0, 0, 2, 2); + bool spatialIndexWorks = false; + foreach (var c in chunksInRegion) + { + if (c.Equals(chunk2D)) + { + spatialIndexWorks = true; + break; + } + } + + // Test threading + bool threadingWorks = ThreadingManager != null; + + // Clean up + manager2D.UnloadChunk(1, 1); + manager3D.UnloadChunk(1, 1, 1); + + return resourceTracking2D && resourceTracking3D && constraintsWork && + spatialIndexWorks && threadingWorks; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/AdvChkSys.csproj b/advchksys/src/AdvChkSys/AdvChkSys.csproj new file mode 100644 index 0000000..41e37aa --- /dev/null +++ b/advchksys/src/AdvChkSys/AdvChkSys.csproj @@ -0,0 +1,9 @@ + + + netstandard2.1 + 9.0 + enable + AdvChkSys + true + + \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs b/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs new file mode 100644 index 0000000..b4c1a52 --- /dev/null +++ b/advchksys/src/AdvChkSys/Chunk/Chunk2D.cs @@ -0,0 +1,165 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Chunk +{ + /// + /// Represents a 2D chunk of data in the world. + /// Supports all-air singleton for memory efficiency and chunk array pooling. + /// + public class Chunk2D : IChunk, IDisposable + { + // Array pool for chunk data arrays + private static readonly ConcurrentBag _arrayPool = new(); + + /// + /// Returns a singleton all-air chunk for the given size and position. + /// + public static Chunk2D AllAir(int x, int y, int width, int height) + { + return new Chunk2D(x, y, width, height, isAllAir: true); + } + + private readonly bool _isAllAir; + private T[,]? _data; + + // Properties required by IChunk + /// + /// The chunk's X position in chunk coordinates. + /// + public int X { get; private set; } + /// + /// The chunk's Y position in chunk coordinates. + /// + public int Y { get; private set; } + /// + /// The width of the chunk in cells. + /// + public int Width { get; } + /// + /// The height of the chunk in cells. + /// + public int Height { get; } + /// + /// Metadata dictionary for arbitrary chunk information. + /// + public Dictionary Metadata { get; } + + private bool _disposed; + + /// + /// Normal constructor (private for singleton/factory). + /// + public Chunk2D(int x, int y, int width, int height, bool isAllAir = false) + { + X = x; + Y = y; + Width = width; + Height = height; + _isAllAir = isAllAir; + _data = isAllAir ? null : RentArray(width, height); + Metadata = new Dictionary(); + } + + /// + /// Explicitly sets the chunk's position. + /// + internal void SetPosition(int x, int y) + { + X = x; + Y = y; + } + + /// + /// Gets or sets the value at the given local chunk coordinates. + /// + public T this[int localX, int localY] + { + get + { + if (localX < 0 || localX >= Width || localY < 0 || localY >= Height) + throw new ArgumentOutOfRangeException($"Coordinates ({localX}, {localY}) out of bounds [0-{Width - 1}, 0-{Height - 1}]"); + return _isAllAir ? default! : _data![localX, localY]; + } + set + { + if (localX < 0 || localX >= Width || localY < 0 || localY >= Height) + throw new ArgumentOutOfRangeException($"Coordinates ({localX}, {localY}) out of bounds [0-{Width - 1}, 0-{Height - 1}]"); + if (_isAllAir) + throw new InvalidOperationException("Cannot set cell in all-air chunk."); + _data![localX, localY] = value; + } + } + + /// + /// Fills the chunk with a specified value (no-op for all-air). + /// + public void Fill(T value) + { + if (_isAllAir) return; + var data = _data!; + for (int x = 0; x < Width; x++) + for (int y = 0; y < Height; y++) + data[x, y] = value; + } + + /// + /// Returns true if this chunk is the all-air singleton. + /// + public bool IsAllAir => _isAllAir; + + /// + /// Returns the underlying data array (for pooling). + /// + internal T[,]? GetDataArray() => _data; + + /// + /// Releases the data array back to the pool (for chunk manager). + /// + internal void ReleaseDataArray() + { + if (_data != null) + { + if (!_isAllAir) + { + ReturnArray(_data); + } + _data = null; + } + } + + /// + /// Rents an array from the pool or creates a new one. + /// + private static T[,] RentArray(int width, int height) + { + if (_arrayPool.TryTake(out var arr) && arr.GetLength(0) == width && arr.GetLength(1) == height) + return arr; + return new T[width, height]; + } + + /// + /// Returns an array to the pool. + /// + internal static void ReturnArray(T[,] arr) + { + Array.Clear(arr, 0, arr.Length); + _arrayPool.Add(arr); + } + + /// + /// Disposes the chunk and returns its resources to the pool. + /// + public void Dispose() + { + if (!_disposed) + { + ReleaseDataArray(); + _disposed = true; + } + } + } +} diff --git a/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs b/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs new file mode 100644 index 0000000..643bf98 --- /dev/null +++ b/advchksys/src/AdvChkSys/Chunk/Chunk3D.cs @@ -0,0 +1,206 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Chunk +{ + /// + /// Represents a 3D chunk of data in the world. + /// Perspective-agnostic and supports arbitrary data types and metadata. + /// + public class Chunk3D : IChunk, IDisposable + { + // Array pool for chunk data arrays + private static readonly ConcurrentBag _arrayPool = new(); + + /// + /// Returns a singleton all-air chunk for the given size and position. + /// + public static Chunk3D AllAir(int x, int y, int z, int width, int height, int depth) + { + return new Chunk3D(x, y, z, width, height, depth, isAllAir: true); + } + + /// + /// The chunk's X position in chunk coordinates. + /// + public int X { get; private set; } + + /// + /// The chunk's Y position in chunk coordinates. + /// + public int Y { get; private set; } + + /// + /// The chunk's Z position in chunk coordinates. + /// + public int Z { get; private set; } + + /// + /// The width of the chunk in cells. + /// + public int Width { get; } + + /// + /// The height of the chunk in cells. + /// + public int Height { get; } + + /// + /// The depth of the chunk in cells. + /// + public int Depth { get; } + + /// + /// The chunk's data array. + /// + private readonly T[,,]? _data; + + /// + /// Metadata dictionary for arbitrary chunk information (e.g., biome, tags). + /// + public Dictionary Metadata { get; } + + /// + /// Returns true if this chunk is the all-air singleton. + /// + public bool IsAllAir { get; private set; } = false; + + /// + /// Tracks whether the chunk has been disposed. + /// + private bool _disposed; + + /// + /// Creates a new 3D chunk at the specified position with the given dimensions. + /// + /// X coordinate in chunk space + /// Y coordinate in chunk space + /// Z coordinate in chunk space + /// Width of the chunk in cells + /// Height of the chunk in cells + /// Depth of the chunk in cells + public Chunk3D(int x, int y, int z, int width, int height, int depth) + : this(x, y, z, width, height, depth, false) + { + } + + /// + /// Creates a new 3D chunk at the specified position with the given dimensions. + /// + /// X coordinate in chunk space + /// Y coordinate in chunk space + /// Z coordinate in chunk space + /// Width of the chunk in cells + /// Height of the chunk in cells + /// Depth of the chunk in cells + /// Whether this is an all-air chunk + public Chunk3D(int x, int y, int z, int width, int height, int depth, bool isAllAir = false) + { + X = x; + Y = y; + Z = z; + Width = width; + Height = height; + Depth = depth; + IsAllAir = isAllAir; + _data = isAllAir ? null : RentArray(width, height, depth); + Metadata = new Dictionary(); + } + + /// + /// Explicitly sets the chunk's position. + /// + internal void SetPosition(int x, int y, int z) + { + X = x; + Y = y; + Z = z; + } + + /// + /// Gets or sets the value at the given local chunk coordinates. + /// + public T this[int localX, int localY, int localZ] + { + get + { + if (IsAllAir) + return default!; + return _data![localX, localY, localZ]; + } + set + { + if (IsAllAir) + throw new InvalidOperationException("Cannot modify an all-air chunk."); + _data![localX, localY, localZ] = value; + } + } + + /// + /// Fills the chunk with a specified value. + /// + public void Fill(T value) + { + if (IsAllAir) + throw new InvalidOperationException("Cannot fill an all-air chunk."); + + for (int x = 0; x < Width; x++) + for (int y = 0; y < Height; y++) + for (int z = 0; z < Depth; z++) + _data![x, y, z] = value; + } + + /// + /// Rents an array from the pool or creates a new one. + /// + private static T[,,] RentArray(int width, int height, int depth) + { + if (_arrayPool.TryTake(out var arr) && + arr.GetLength(0) == width && + arr.GetLength(1) == height && + arr.GetLength(2) == depth) + return arr; + return new T[width, height, depth]; + } + + /// + /// Returns an array to the pool. + /// + internal static void ReturnArray(T[,,] arr) + { + Array.Clear(arr, 0, arr.Length); + _arrayPool.Add(arr); + } + + /// + /// Returns the underlying data array (for pooling). + /// + internal T[,,]? GetDataArray() => _data; + + /// + /// Releases the data array back to the pool. + /// + internal void ReleaseDataArray() + { + if (_data != null && !IsAllAir) + { + ReturnArray(_data); + } + } + + /// + /// Disposes the chunk and returns its resources to the pool. + /// + public void Dispose() + { + if (!_disposed) + { + ReleaseDataArray(); + _disposed = true; + } + } + } +} diff --git a/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs b/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs new file mode 100644 index 0000000..5362272 --- /dev/null +++ b/advchksys/src/AdvChkSys/Constraints/WorldConstraints.cs @@ -0,0 +1,67 @@ +using System; + +namespace AdvChkSys.Constraints +{ + /// + /// Represents constraints and limits for the chunk world. + /// Can be used to restrict world size, chunk counts, and other resource limits. + /// + public class WorldConstraints + { + /// + /// If set, the minimum allowed chunk X coordinate (inclusive). + /// + public int? MinChunkX { get; set; } + + /// + /// If set, the maximum allowed chunk X coordinate (inclusive). + /// + public int? MaxChunkX { get; set; } + + /// + /// If set, the minimum allowed chunk Y coordinate (inclusive). + /// + public int? MinChunkY { get; set; } + + /// + /// If set, the maximum allowed chunk Y coordinate (inclusive). + /// + public int? MaxChunkY { get; set; } + + /// + /// If set, the minimum allowed chunk Z coordinate (inclusive). + /// + public int? MinChunkZ { get; set; } + + /// + /// If set, the maximum allowed chunk Z coordinate (inclusive). + /// + public int? MaxChunkZ { get; set; } + + /// + /// If set, the maximum number of chunks allowed to be loaded in memory at once. + /// + public int? MaxLoadedChunks { get; set; } + + /// + /// Checks if the given chunk coordinates are within the allowed world bounds. + /// + public bool IsWithinBounds(int chunkX, int chunkY) + { + if (MinChunkX.HasValue && chunkX < MinChunkX.Value) return false; + if (MaxChunkX.HasValue && chunkX > MaxChunkX.Value) return false; + if (MinChunkY.HasValue && chunkY < MinChunkY.Value) return false; + if (MaxChunkY.HasValue && chunkY > MaxChunkY.Value) return false; + return true; + } + + /// + /// Checks if the current number of loaded chunks is within the allowed limit. + /// + public bool IsWithinChunkLimit(int loadedChunkCount) + { + if (MaxLoadedChunks.HasValue && loadedChunkCount > MaxLoadedChunks.Value) return false; + return true; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs b/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs new file mode 100644 index 0000000..0b3be04 --- /dev/null +++ b/advchksys/src/AdvChkSys/Dependencies/ChunkDependencyTracker.cs @@ -0,0 +1,325 @@ +#nullable enable +using System.Collections.Generic; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Dependencies +{ + /// + /// Tracks dependencies between chunks. + /// + public class ChunkDependencyTracker + { + /// + /// Defines the type of dependency between chunks. + /// + public enum DependencyType + { + /// + /// Indicates a neighboring chunk relationship. + /// + Neighbor, + + /// + /// Indicates a reference relationship between chunks. + /// + Reference, + + /// + /// Indicates an update relationship between chunks. + /// + Update, + + /// + /// Indicates a custom dependency relationship. + /// + Custom + } + + // Dictionary to track dependencies: source chunk -> (target chunk, dependency type) + private readonly Dictionary> _dependencies = new(); + + // Dictionary to track dependents: target chunk -> (source chunk, dependency type) + private readonly Dictionary> _dependents = new(); + + // Lock object for thread safety + private readonly object _lock = new(); + + /// + /// Registers a dependency between two chunks. + /// + public void RegisterDependency(IChunk source, IChunk target, DependencyType type) + { + if (source == null || target == null) + return; + + lock (_lock) + { + // Add to dependencies + if (!_dependencies.TryGetValue(source, out var targetSet)) + { + targetSet = new HashSet<(IChunk, DependencyType)>(); + _dependencies[source] = targetSet; + } + targetSet.Add((target, type)); + + // Add to dependents + if (!_dependents.TryGetValue(target, out var sourceSet)) + { + sourceSet = new HashSet<(IChunk, DependencyType)>(); + _dependents[target] = sourceSet; + } + sourceSet.Add((source, type)); + } + } + + /// + /// Removes a dependency between two chunks. + /// + public void RemoveDependency(IChunk source, IChunk target) + { + if (source == null || target == null) + return; + + lock (_lock) + { + // Remove from dependencies + if (_dependencies.TryGetValue(source, out var targetSet)) + { + targetSet.RemoveWhere(t => t.Target.Equals(target)); + if (targetSet.Count == 0) + { + _dependencies.Remove(source); + } + } + + // Remove from dependents + if (_dependents.TryGetValue(target, out var sourceSet)) + { + sourceSet.RemoveWhere(s => s.Source.Equals(source)); + if (sourceSet.Count == 0) + { + _dependents.Remove(target); + } + } + } + } + + /// + /// Gets all chunks that depend on the specified chunk. + /// + public IEnumerable GetDependents(IChunk chunk) + { + if (chunk == null) + return new List(); + + lock (_lock) + { + if (!_dependents.TryGetValue(chunk, out var sourceSet)) + { + return new List(); + } + + var result = new List(sourceSet.Count); + foreach (var (source, _) in sourceSet) + { + result.Add(source); + } + return result; + } + } + + /// + /// Gets all chunks that the specified chunk depends on. + /// + public IEnumerable GetDependencies(IChunk chunk) + { + if (chunk == null) + return new List(); + + lock (_lock) + { + if (!_dependencies.TryGetValue(chunk, out var targetSet)) + { + return new List(); + } + + var result = new List(targetSet.Count); + foreach (var (target, _) in targetSet) + { + result.Add(target); + } + return result; + } + } + + /// + /// Gets all chunks that depend on the specified chunk with their dependency types. + /// + public IEnumerable<(IChunk Chunk, DependencyType Type)> GetDependentsWithTypes(IChunk chunk) + { + if (chunk == null) + return new List<(IChunk, DependencyType)>(); + + lock (_lock) + { + if (!_dependents.TryGetValue(chunk, out var sourceDependents)) + { + return new List<(IChunk, DependencyType)>(); + } + + var result = new List<(IChunk, DependencyType)>(sourceDependents.Count); + foreach (var (source, type) in sourceDependents) + { + result.Add((source, type)); + } + return result; + } + } + + /// + /// Gets all chunks that the specified chunk depends on with their dependency types. + /// + public IEnumerable<(IChunk Chunk, DependencyType Type)> GetDependenciesWithTypes(IChunk chunk) + { + if (chunk == null) + return new List<(IChunk, DependencyType)>(); + + lock (_lock) + { + if (!_dependencies.TryGetValue(chunk, out var targetSet)) + { + return new List<(IChunk, DependencyType)>(); + } + + var result = new List<(IChunk, DependencyType)>(targetSet.Count); + foreach (var (target, type) in targetSet) + { + result.Add((target, type)); + } + return result; + } + } + + /// + /// Checks if a dependency exists between source and target chunks. + /// + public bool HasDependency(IChunk source, IChunk target) + { + if (source == null || target == null) + return false; + + lock (_lock) + { + if (!_dependencies.TryGetValue(source, out var targetSet)) + { + return false; + } + + foreach (var (t, _) in targetSet) + { + if (t.Equals(target)) + { + return true; + } + } + + return false; + } + } + + /// + /// Gets the dependency type between source and target chunks, or null if no dependency exists. + /// + public DependencyType? GetDependencyType(IChunk source, IChunk target) + { + if (source == null || target == null) + return null; + + lock (_lock) + { + if (!_dependencies.TryGetValue(source, out var targetSet)) + { + return null; + } + + foreach (var (t, type) in targetSet) + { + if (t.Equals(target)) + { + return type; + } + } + + return null; + } + } + + /// + /// Clears all dependencies for a specific chunk. + /// + public void ClearDependencies(IChunk chunk) + { + if (chunk == null) + return; + + lock (_lock) + { + // Remove all dependencies where this chunk is the source + if (_dependencies.TryGetValue(chunk, out var targetSet)) + { + foreach (var (target, _) in targetSet) + { + if (_dependents.TryGetValue(target, out var depSources)) + { + depSources.RemoveWhere(s => s.Source.Equals(chunk)); + if (depSources.Count == 0) + { + _dependents.Remove(target); + } + } + } + _dependencies.Remove(chunk); + } + + // Remove all dependencies where this chunk is the target + if (_dependents.TryGetValue(chunk, out var sourceSets)) + { + foreach (var (source, _) in sourceSets) + { + if (_dependencies.TryGetValue(source, out var depTargets)) + { + depTargets.RemoveWhere(t => t.Target.Equals(chunk)); + if (depTargets.Count == 0) + { + _dependencies.Remove(source); + } + } + } + _dependents.Remove(chunk); + } + } + } + + /// + /// Gets all chunks that have any dependencies. + /// + public IEnumerable GetAllChunksWithDependencies() + { + lock (_lock) + { + return new HashSet(_dependencies.Keys); + } + } + + /// + /// Gets all chunks that have any dependents. + /// + public IEnumerable GetAllChunksWithDependents() + { + lock (_lock) + { + return new HashSet(_dependents.Keys); + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs b/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs new file mode 100644 index 0000000..ce42c3a --- /dev/null +++ b/advchksys/src/AdvChkSys/Diagnostics/MemoryUsageReporter.cs @@ -0,0 +1,142 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using AdvChkSys.Resources; +using AdvChkSys.Util; + +namespace AdvChkSys.Diagnostics +{ + /// + /// Provides memory usage statistics and reporting for the AdvChkSys library. + /// Helps client code monitor memory consumption of chunks and related resources. + /// + public static class MemoryUsageReporter + { + /// + /// Gets the current memory usage statistics for the AdvChkSys library. + /// + /// A MemoryUsageReport containing detailed memory statistics + public static MemoryUsageReport GetMemoryUsage() + { + var report = new MemoryUsageReport + { + ActiveChunkCount = ChunkResourceManager.GetActiveChunkCount(), + TotalSystemMemoryBytes = GetTotalSystemMemory(), + AvailableSystemMemoryBytes = MemoryHelper.GetAvailableMemoryBytes(), + Timestamp = DateTime.UtcNow + }; + + // Calculate estimated chunk memory usage + report.EstimatedChunkMemoryBytes = EstimateChunkMemoryUsage(report.ActiveChunkCount); + + return report; + } + + /// + /// Estimates the memory usage of chunks based on the active chunk count and average chunk size. + /// + private static ulong EstimateChunkMemoryUsage(int chunkCount) + { + // This is an estimate based on typical chunk sizes + // For more accurate reporting, we would need to track actual sizes + const int averageChunkSizeBytes = 32 * 32 * 4; // Assuming 32x32 chunks with 4 bytes per cell on average + return (ulong)chunkCount * (ulong)averageChunkSizeBytes; + } + + /// + /// Gets the total system memory in bytes. + /// + private static ulong GetTotalSystemMemory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var memStatus = new MemoryHelper.MEMORYSTATUSEX + { + dwLength = (uint)Marshal.SizeOf(typeof(MemoryHelper.MEMORYSTATUSEX)) + }; + if (MemoryHelper.GlobalMemoryStatusEx(ref memStatus)) + { + return memStatus.ullTotalPhys; + } + } + + // For non-Windows platforms or if Windows call fails, use a fallback + return 8UL * 1024 * 1024 * 1024; // Assume 8GB as fallback + } + + /// + /// Logs memory usage information to the provided action. + /// + /// Action to handle the log message + public static void LogMemoryUsage(Action logAction) + { + var report = GetMemoryUsage(); + + logAction($"--- AdvChkSys Memory Report ({report.Timestamp:yyyy-MM-dd HH:mm:ss}) ---"); + logAction($"Active Chunks: {report.ActiveChunkCount:N0}"); + logAction($"Estimated Chunk Memory: {FormatByteSize(report.EstimatedChunkMemoryBytes)}"); + logAction($"Available System Memory: {FormatByteSize(report.AvailableSystemMemoryBytes)}"); + logAction($"Total System Memory: {FormatByteSize(report.TotalSystemMemoryBytes)}"); + logAction($"Memory Usage %: {report.MemoryUsagePercentage:F2}%"); + logAction("-------------------------------------------"); + } + + /// + /// Formats a byte size into a human-readable string (KB, MB, GB). + /// + private static string FormatByteSize(ulong bytes) + { + string[] sizes = { "B", "KB", "MB", "GB", "TB" }; + double formattedSize = bytes; + int order = 0; + + while (formattedSize >= 1024 && order < sizes.Length - 1) + { + order++; + formattedSize /= 1024; + } + + return $"{formattedSize:F2} {sizes[order]}"; + } + } + + /// + /// Contains detailed memory usage statistics for the AdvChkSys library. + /// + public class MemoryUsageReport + { + /// + /// The number of active chunks currently being managed. + /// + public int ActiveChunkCount { get; internal set; } + + /// + /// Estimated memory usage of all chunks in bytes. + /// + public ulong EstimatedChunkMemoryBytes { get; internal set; } + + /// + /// Available system memory in bytes. + /// + public ulong AvailableSystemMemoryBytes { get; internal set; } + + /// + /// Total system memory in bytes. + /// + public ulong TotalSystemMemoryBytes { get; internal set; } + + /// + /// Timestamp when this report was generated. + /// + public DateTime Timestamp { get; internal set; } + + /// + /// Percentage of total system memory used by chunks. + /// + public double MemoryUsagePercentage => + TotalSystemMemoryBytes > 0 + ? (double)EstimatedChunkMemoryBytes / TotalSystemMemoryBytes * 100 + : 0; + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Events/ChunkEvents.cs b/advchksys/src/AdvChkSys/Events/ChunkEvents.cs new file mode 100644 index 0000000..5afd54e --- /dev/null +++ b/advchksys/src/AdvChkSys/Events/ChunkEvents.cs @@ -0,0 +1,126 @@ +#nullable enable +using System; +using System.Threading; + +namespace AdvChkSys.Events +{ + /// + /// Provides events for chunk lifecycle operations such as loading, unloading, saving, and more. + /// Thread-safe event subscription and invocation. + /// + public static class ChunkEvents + { + // Backing fields for thread-safe event handling + private static Action? _chunkLoaded; + private static Action? _chunkUnloaded; + private static Action? _chunkLoading; + private static Action? _chunkUnloading; + private static Action? _chunkSaving; + private static Action? _chunkSaved; + + /// + /// Occurs when a chunk has been loaded into memory. + /// + public static event Action ChunkLoaded + { + add => AddHandler(ref _chunkLoaded, value); + remove => RemoveHandler(ref _chunkLoaded, value); + } + + /// + /// Occurs when a chunk is about to be loaded into memory. + /// + public static event Action ChunkLoading + { + add => AddHandler(ref _chunkLoading, value); + remove => RemoveHandler(ref _chunkLoading, value); + } + + /// + /// Occurs when a chunk is about to be unloaded from memory. + /// + public static event Action ChunkUnloading + { + add => AddHandler(ref _chunkUnloading, value); + remove => RemoveHandler(ref _chunkUnloading, value); + } + + /// + /// Occurs when a chunk has been unloaded from memory. + /// + public static event Action ChunkUnloaded + { + add => AddHandler(ref _chunkUnloaded, value); + remove => RemoveHandler(ref _chunkUnloaded, value); + } + + /// + /// Occurs when a chunk is about to be saved. + /// + public static event Action ChunkSaving + { + add => AddHandler(ref _chunkSaving, value); + remove => RemoveHandler(ref _chunkSaving, value); + } + + /// + /// Occurs when a chunk has been saved. + /// + public static event Action ChunkSaved + { + add => AddHandler(ref _chunkSaved, value); + remove => RemoveHandler(ref _chunkSaved, value); + } + + /// + /// Raises the ChunkLoading event. + /// + public static void OnChunkLoading(Interfaces.IChunk chunk) => _chunkLoading?.Invoke(chunk); + + /// + /// Raises the ChunkLoaded event. + /// + public static void OnChunkLoaded(Interfaces.IChunk chunk) => _chunkLoaded?.Invoke(chunk); + + /// + /// Raises the ChunkUnloading event. + /// + public static void OnChunkUnloading(Interfaces.IChunk chunk) => _chunkUnloading?.Invoke(chunk); + + /// + /// Raises the ChunkUnloaded event. + /// + public static void OnChunkUnloaded(Interfaces.IChunk chunk) => _chunkUnloaded?.Invoke(chunk); + + /// + /// Raises the ChunkSaving event. + /// + public static void OnChunkSaving(Interfaces.IChunk chunk) => _chunkSaving?.Invoke(chunk); + + /// + /// Raises the ChunkSaved event. + /// + public static void OnChunkSaved(Interfaces.IChunk chunk) => _chunkSaved?.Invoke(chunk); + + // Thread-safe add/remove for event handlers + private static void AddHandler(ref Action? field, Action handler) + { + Action? prevHandler, newHandler; + do + { + prevHandler = field; + newHandler = (Action?)Delegate.Combine(prevHandler, handler); + } while (Interlocked.CompareExchange(ref field, newHandler, prevHandler) != prevHandler); + } + + private static void RemoveHandler(ref Action? field, Action handler) + { + Action? prevHandler, newHandler; + do + { + prevHandler = field; + newHandler = (Action?)Delegate.Remove(prevHandler, handler); + } while (Interlocked.CompareExchange(ref field, newHandler, prevHandler) != prevHandler); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Interfaces/IChunk.cs b/advchksys/src/AdvChkSys/Interfaces/IChunk.cs new file mode 100644 index 0000000..b83440e --- /dev/null +++ b/advchksys/src/AdvChkSys/Interfaces/IChunk.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace AdvChkSys.Interfaces +{ + /// + /// Represents a generic chunk in the world. + /// Provides basic properties for position, size, and metadata. + /// + public interface IChunk + { + /// + /// The chunk's X position in chunk-space coordinates. + /// + int X { get; } + + /// + /// The chunk's Y position in chunk-space coordinates. + /// + int Y { get; } + + /// + /// The width of the chunk (in cells/tiles/units). + /// + int Width { get; } + + /// + /// The height of the chunk (in cells/tiles/units). + /// + int Height { get; } + + /// + /// Metadata dictionary for arbitrary chunk information (e.g., biome, tags). + /// + Dictionary Metadata { get; } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs b/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs new file mode 100644 index 0000000..dd097e9 --- /dev/null +++ b/advchksys/src/AdvChkSys/Interfaces/IChunkManager.cs @@ -0,0 +1,38 @@ +#nullable enable +using System.Collections.Generic; + +namespace AdvChkSys.Interfaces +{ + /// + /// Interface for managing chunks in memory. + /// Provides methods for loading, unloading, and accessing chunks. + /// + public interface IChunkManager + { + /// + /// Returns true if the chunk at (x, y) is loaded. + /// + bool IsChunkLoaded(int x, int y); + + /// + /// Gets the chunk at (x, y) if loaded, or null if not. + /// + IChunk? GetChunk(int x, int y); + + /// + /// Loads or creates a chunk at (x, y) with the given size. + /// Returns the loaded or newly created chunk. + /// + IChunk LoadOrCreateChunk(int x, int y, int width, int height); + + /// + /// Unloads (removes) the chunk at (x, y) if loaded. + /// + bool UnloadChunk(int x, int y); + + /// + /// Enumerates all loaded chunks. + /// + IEnumerable GetAllChunks(); + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs b/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs new file mode 100644 index 0000000..4d44ef1 --- /dev/null +++ b/advchksys/src/AdvChkSys/Loading/ChunkLoadingPriority.cs @@ -0,0 +1,696 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; +using AdvChkSys.Threading; + +namespace AdvChkSys.Loading +{ + /// + /// Manages prioritized loading of chunks. + /// + public class ChunkLoadingPriority + { + /// + /// Priority levels for chunk loading. + /// + public enum Priority + { + /// + /// Immediate priority - process as soon as possible + /// + Immediate, + + /// + /// High priority - process after immediate requests + /// + High, + + /// + /// Normal priority - standard processing order + /// + Normal, + + /// + /// Low priority - process after normal requests + /// + Low, + + /// + /// Background priority - process when system is idle + /// + Background + } + + /// + /// Represents a chunk loading request with priority. + /// + public class ChunkLoadRequest + { + /// + /// The X coordinate of the chunk. + /// + public int X { get; } + + /// + /// The Y coordinate of the chunk. + /// + public int Y { get; } + + /// + /// The Z coordinate of the chunk (optional, for 3D chunks). + /// + public int Z { get; } + + /// + /// The priority of this loading request. + /// + public Priority LoadPriority { get; } + + /// + /// The width of the chunk. + /// + public int Width { get; } + + /// + /// The height of the chunk. + /// + public int Height { get; } + + /// + /// The depth of the chunk (for 3D chunks). + /// + public int Depth { get; } + + /// + /// Timestamp when the request was created. + /// + public DateTime Timestamp { get; } + + /// + /// Unique identifier for this request. + /// + public Guid RequestId { get; } + + /// + /// Creates a new 2D chunk loading request. + /// + public ChunkLoadRequest(int x, int y, int width, int height, Priority priority) + { + X = x; + Y = y; + Z = 0; + Width = width; + Height = height; + Depth = 1; + LoadPriority = priority; + Timestamp = DateTime.UtcNow; + RequestId = Guid.NewGuid(); + } + + /// + /// Creates a new 3D chunk loading request. + /// + public ChunkLoadRequest(int x, int y, int z, int width, int height, int depth, Priority priority) + { + X = x; + Y = y; + Z = z; + Width = width; + Height = height; + Depth = depth; + LoadPriority = priority; + Timestamp = DateTime.UtcNow; + RequestId = Guid.NewGuid(); + } + } + + // Priority queues for each priority level + private readonly Dictionary> _requestQueues = new(); + + // Lookup for fast cancellation and status checks + private readonly Dictionary _requestLookup = new(); + + // Active tasks to prevent duplicate loading + private readonly Dictionary<(int X, int Y, int Z), Task> _activeTasks = new(); + + // Synchronization + private readonly SemaphoreSlim _queueSemaphore = new(1, 1); + private readonly SemaphoreSlim _processSemaphore; + private readonly CancellationTokenSource _cancellationSource = new(); + + // Processing state + private bool _isProcessing; + private Task? _processingTask; + + // Configuration + private readonly int _maxConcurrentLoads; + private readonly TimeSpan _requestTimeout; + + /// + /// Event raised when a chunk load request is completed. + /// + public event EventHandler? RequestCompleted; + + /// + /// Event raised when a chunk load request fails. + /// + public event EventHandler<(ChunkLoadRequest Request, Exception Exception)>? RequestFailed; + + /// + /// Initializes a new instance of the ChunkLoadingPriority class. + /// + /// Maximum number of chunks to load concurrently + /// Timeout in seconds for chunk load requests + public ChunkLoadingPriority(int maxConcurrentLoads = 4, int requestTimeoutSeconds = 30) + { + _maxConcurrentLoads = maxConcurrentLoads; + _requestTimeout = TimeSpan.FromSeconds(requestTimeoutSeconds); + _processSemaphore = new SemaphoreSlim(maxConcurrentLoads, maxConcurrentLoads); + + // Initialize queues for each priority level + foreach (Priority priority in Enum.GetValues(typeof(Priority))) + { + _requestQueues[priority] = new Queue(); + } + } + + /// + /// Enqueues a chunk load request with the specified priority. + /// + /// The request object that can be used to track or cancel the request + public ChunkLoadRequest EnqueueRequest(int x, int y, int width, int height, Priority priority = Priority.Normal) + { + var request = new ChunkLoadRequest(x, y, width, height, priority); + EnqueueRequest(request); + return request; + } + + /// + /// Enqueues a 3D chunk load request with the specified priority. + /// + /// The request object that can be used to track or cancel the request + public ChunkLoadRequest EnqueueRequest(int x, int y, int z, int width, int height, int depth, Priority priority = Priority.Normal) + { + var request = new ChunkLoadRequest(x, y, z, width, height, depth, priority); + EnqueueRequest(request); + return request; + } + + /// + /// Enqueues a pre-created chunk load request. + /// + public void EnqueueRequest(ChunkLoadRequest request) + { + _queueSemaphore.Wait(); + try + { + // Check if we already have a request for this chunk + var key = (request.X, request.Y, request.Z); + if (_activeTasks.ContainsKey(key)) + { + return; // Already loading this chunk + } + + // Add to appropriate queue + _requestQueues[request.LoadPriority].Enqueue(request); + _requestLookup[request.RequestId] = request; + + // Start processing if not already running + EnsureProcessingStarted(); + } + finally + { + _queueSemaphore.Release(); + } + + // For immediate priority, wait for processing to start + if (request.LoadPriority == Priority.Immediate) + { + TriggerImmediateProcessing(); + } + } + + /// + /// Cancels a pending chunk load request. + /// + /// True if the request was found and canceled, false otherwise + public bool CancelRequest(Guid requestId) + { + _queueSemaphore.Wait(); + try + { + if (_requestLookup.TryGetValue(requestId, out var request)) + { + _requestLookup.Remove(requestId); + // Note: We don't remove from the queue as that would be inefficient + // Instead, we'll skip it when we dequeue + return true; + } + return false; + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Cancels all pending chunk load requests. + /// + public void CancelAllRequests() + { + _queueSemaphore.Wait(); + try + { + foreach (var queue in _requestQueues.Values) + { + queue.Clear(); + } + _requestLookup.Clear(); + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Starts the processing of chunk load requests if not already running. + /// + private void EnsureProcessingStarted() + { + if (_isProcessing) + return; + + _isProcessing = true; + _processingTask = Task.Run(ProcessRequestsAsync); + } + + /// + /// Triggers immediate processing of high-priority requests. + /// + private void TriggerImmediateProcessing() + { + // This is a hint to the processor to check for immediate requests now + // We don't need to do anything special as the processor checks immediate first + } + + /// + /// Main processing loop for chunk load requests. + /// + private async Task ProcessRequestsAsync() + { + while (!_cancellationSource.IsCancellationRequested) + { + ChunkLoadRequest? request = null; + + // Get the next request from the highest priority queue + await _queueSemaphore.WaitAsync(); + try + { + request = DequeueNextRequest(); + + if (request == null) + { + // No requests to process, pause briefly + _isProcessing = false; + _queueSemaphore.Release(); + await Task.Delay(50); + continue; + } + + // Mark this chunk as being processed + var key = (request.X, request.Y, request.Z); + if (_activeTasks.ContainsKey(key)) + { + // Already processing this chunk, skip + continue; + } + + // Create a task for this request but don't start it yet + var loadTask = ProcessRequestAsync(request); + _activeTasks[key] = loadTask; + } + finally + { + if (_isProcessing) + { + _queueSemaphore.Release(); + } + } + + // Wait for a processing slot + await _processSemaphore.WaitAsync(); + + // Start the task and continue without waiting + _ = Task.Run(async () => + { + try + { + await ProcessRequestAsync(request!); + } + finally + { + _processSemaphore.Release(); + + // Remove from active tasks + await _queueSemaphore.WaitAsync(); + try + { + _activeTasks.Remove((request!.X, request.Y, request.Z)); + } + finally + { + _queueSemaphore.Release(); + } + } + }); + } + } + + /// + /// Dequeues the next request from the highest priority queue. + /// + private ChunkLoadRequest? DequeueNextRequest() + { + // Check each priority level in order + foreach (Priority priority in Enum.GetValues(typeof(Priority))) + { + var queue = _requestQueues[priority]; + + while (queue.Count > 0) + { + var request = queue.Dequeue(); + + // Skip if the request has been canceled + if (!_requestLookup.ContainsKey(request.RequestId)) + continue; + + // Skip if the request has timed out + if (DateTime.UtcNow - request.Timestamp > _requestTimeout) + { + _requestLookup.Remove(request.RequestId); + continue; + } + + // Valid request found + return request; + } + } + + return null; // No valid requests found + } + + /// + /// Processes a single chunk load request. + /// + private async Task ProcessRequestAsync(ChunkLoadRequest request) + { + try + { + // This would be integrated with your chunk manager + // For now, we'll just simulate the loading with a delay + + // Simulate different loading times based on priority + int delayMs = request.LoadPriority switch + { + Priority.Immediate => 50, + Priority.High => 100, + Priority.Normal => 200, + Priority.Low => 500, + Priority.Background => 1000, + _ => 200 + }; + + await Task.Delay(delayMs); + + // Notify completion + RequestCompleted?.Invoke(this, request); + } + catch (Exception ex) + { + // Notify failure + RequestFailed?.Invoke(this, (request, ex)); + } + finally + { + // Remove from lookup + await _queueSemaphore.WaitAsync(); + try + { + _requestLookup.Remove(request.RequestId); + } + finally + { + _queueSemaphore.Release(); + } + } + } + + /// + /// Gets the number of pending requests for each priority level. + /// + public Dictionary GetQueueSizes() + { + _queueSemaphore.Wait(); + try + { + var result = new Dictionary(); + foreach (var kvp in _requestQueues) + { + result[kvp.Key] = kvp.Value.Count; + } + return result; + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Gets the total number of pending requests. + /// + public int GetTotalQueueSize() + { + _queueSemaphore.Wait(); + try + { + int total = 0; + foreach (var queue in _requestQueues.Values) + { + total += queue.Count; + } + return total; + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Stops processing and releases resources. + /// + public async Task ShutdownAsync() + { + _cancellationSource.Cancel(); + + if (_processingTask != null) + { + try + { + await _processingTask; + } + catch (OperationCanceledException) + { + // Expected + } + catch (Exception) + { + // Ignore other exceptions during shutdown + } + } + + _queueSemaphore.Dispose(); + _processSemaphore.Dispose(); + _cancellationSource.Dispose(); + } + + /// + /// Creates a ChunkLoadingPriority instance with default settings. + /// + public static ChunkLoadingPriority CreateDefault() + { + return new ChunkLoadingPriority(); + } + + /// + /// Creates a ChunkLoadingPriority instance optimized for high throughput. + /// + public static ChunkLoadingPriority CreateHighThroughput() + { + return new ChunkLoadingPriority( + maxConcurrentLoads: Environment.ProcessorCount * 2, + requestTimeoutSeconds: 60); + } + + /// + /// Creates a ChunkLoadingPriority instance optimized for low latency. + /// + public static ChunkLoadingPriority CreateLowLatency() + { + return new ChunkLoadingPriority( + maxConcurrentLoads: Math.Max(2, Environment.ProcessorCount / 2), + requestTimeoutSeconds: 15); + } + + /// + /// Integrates with a chunk manager to process load requests. + /// + /// The type of data in the chunks + /// The chunk manager to use for loading chunks + public void IntegrateWith(IChunkManager chunkManager) + { + // Replace the ProcessRequestAsync method with one that uses the chunk manager + // This is a simplified example - in a real implementation, you'd need to handle + // both 2D and 3D chunk managers and properly cast the interface + + RequestCompleted += (sender, request) => + { + // The chunk has been loaded, you might want to do something with it + var chunk = chunkManager.GetChunk(request.X, request.Y); + // Additional processing if needed + }; + } + + /// + /// Gets the estimated time until a request with the given priority would be processed. + /// + /// Estimated wait time in milliseconds, or -1 if cannot be determined + public int GetEstimatedWaitTime(Priority priority) + { + _queueSemaphore.Wait(); + try + { + int totalHigherPriorityRequests = 0; + + // Count requests with higher or equal priority + foreach (Priority p in Enum.GetValues(typeof(Priority))) + { + if (p <= priority) + { + totalHigherPriorityRequests += _requestQueues[p].Count; + } + } + + // If no requests or no active tasks, return 0 + if (totalHigherPriorityRequests == 0 || _activeTasks.Count == 0) + { + return 0; + } + + // Estimate based on current processing rate + // This is a very simple estimate and could be improved with actual metrics + int averageProcessingTimeMs = priority switch + { + Priority.Immediate => 50, + Priority.High => 100, + Priority.Normal => 200, + Priority.Low => 500, + Priority.Background => 1000, + _ => 200 + }; + + // Calculate how many batches of concurrent requests we'll need + int batches = (int)Math.Ceiling(totalHigherPriorityRequests / (double)_maxConcurrentLoads); + + return batches * averageProcessingTimeMs; + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Adjusts the priority of an existing request. + /// + /// True if the request was found and its priority adjusted, false otherwise + public bool AdjustPriority(Guid requestId, Priority newPriority) + { + // Note: This is not an efficient operation as we don't directly modify the queue + // Instead, we mark the request for priority change when it's processed + + _queueSemaphore.Wait(); + try + { + if (_requestLookup.TryGetValue(requestId, out var request)) + { + // If the request is of lower priority than requested, we'll re-queue it + if (request.LoadPriority > newPriority) + { + // Remove from lookup (it will be skipped when dequeued) + _requestLookup.Remove(requestId); + + // Create a new request with the same parameters but higher priority + var newRequest = request.Depth > 1 + ? new ChunkLoadRequest(request.X, request.Y, request.Z, request.Width, request.Height, request.Depth, newPriority) + : new ChunkLoadRequest(request.X, request.Y, request.Width, request.Height, newPriority); + + // Add the new request + _requestQueues[newPriority].Enqueue(newRequest); + _requestLookup[newRequest.RequestId] = newRequest; + + // If upgrading to immediate, trigger processing + if (newPriority == Priority.Immediate) + { + TriggerImmediateProcessing(); + } + } + + return true; + } + + return false; + } + finally + { + _queueSemaphore.Release(); + } + } + + /// + /// Gets statistics about the current state of the chunk loading system. + /// + public Dictionary GetStatistics() + { + _queueSemaphore.Wait(); + try + { + var stats = new Dictionary + { + ["TotalPendingRequests"] = _requestLookup.Count, + ["ActiveTasks"] = _activeTasks.Count, + ["IsProcessing"] = _isProcessing, + ["MaxConcurrentLoads"] = _maxConcurrentLoads + }; + + // Add queue sizes for each priority + foreach (Priority priority in Enum.GetValues(typeof(Priority))) + { + stats[$"Queue_{priority}"] = _requestQueues[priority].Count; + } + + return stats; + } + finally + { + _queueSemaphore.Release(); + } + } + } +} diff --git a/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs b/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs new file mode 100644 index 0000000..df1aa92 --- /dev/null +++ b/advchksys/src/AdvChkSys/Manager/ChunkManager2D.cs @@ -0,0 +1,313 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading.Tasks; +using AdvChkSys.Chunk; +using AdvChkSys.Events; +using AdvChkSys.Interfaces; +using AdvChkSys.Resources; +using AdvChkSys.Constraints; +using AdvChkSys.Util; +using System; + +namespace AdvChkSys.Manager +{ + /// + /// Manages 2D chunks in memory. Handles loading, unloading, and access. + /// + public class ChunkManager2D : IChunkManager + { + private readonly LRUCache<(int, int), Chunk2D> _chunks; + private readonly WorldConstraints? _constraints; + private readonly int _capacity; + private readonly Dictionary<(int, int), Task>> _loadingChunks = new(); + private readonly object _lock = new(); + + /// + /// Delegate for custom air check logic + /// + private Func? _airCheckDelegate; + + /// + /// World generator for determining empty regions + /// + private IWorldGenerator? _worldGenerator; + + /// + /// Data provider for determining empty regions + /// + private IDataProvider? _dataProvider; + + /// + /// Height map for determining sky chunks + /// + private IHeightMap? _heightMap; + + /// + /// Initializes a new instance of the 2D chunk manager. + /// + /// Optional world constraints + /// Maximum number of chunks to keep in memory (0 for auto-calculation) + /// Default chunk width in cells + /// Default chunk height in cells + public ChunkManager2D(WorldConstraints? constraints = null, int capacity = 0, int chunkWidth = 32, int chunkHeight = 32) + { + _constraints = constraints; + if (capacity <= 0) + { + // Use dynamic calculation + int bytesPerChunk = chunkWidth * chunkHeight * System.Runtime.InteropServices.Marshal.SizeOf(); + ulong availableBytes = MemoryHelper.GetAvailableMemoryBytes(); + _capacity = (int)(availableBytes * 0.8 / (ulong)bytesPerChunk); + } + else + { + _capacity = capacity; + } + _chunks = new LRUCache<(int, int), Chunk2D>(_capacity); + } + + /// + /// Returns true if the chunk at (x, y) is loaded. + /// + public bool IsChunkLoaded(int x, int y) => _chunks.TryGet((x, y), out _); + + /// + /// Gets the chunk at (x, y) if loaded, or null if not. + /// + public Chunk2D? GetChunk(int x, int y) + { + _chunks.TryGet((x, y), out var chunk); + return chunk; + } + + /// + /// Loads or creates a chunk at (x, y) with the given size. + /// Returns the loaded or newly created chunk. + /// + public Chunk2D LoadOrCreateChunk(int x, int y, int width, int height) + { + if (_constraints != null) + { + if (!_constraints.IsWithinBounds(x, y)) + throw new InvalidOperationException("Chunk coordinates out of bounds."); + if (!_constraints.IsWithinChunkLimit(_chunks.Count + 1)) + throw new InvalidOperationException("Chunk limit exceeded."); + } + + if (!_chunks.TryGet((x, y), out var chunk)) + { + // If this region is all air, use the singleton + if (ShouldBeAllAir(x, y, width, height)) + chunk = Chunk2D.AllAir(x, y, width, height); + else + chunk = new Chunk2D(x, y, width, height); + + _chunks.Add((x, y), chunk, OnChunkEvicted); + ChunkResourceManager.AllocateChunk(chunk); + ChunkEvents.OnChunkLoaded(chunk); + } + return chunk; + } + + private void OnChunkEvicted((int, int) key, Chunk2D chunk) + { + if (chunk == null) return; + + var arr = chunk.GetDataArray(); + if (!chunk.IsAllAir && arr != null) + { + Chunk2D.ReturnArray(arr); + } + ChunkResourceManager.ReleaseChunk(chunk); + ChunkEvents.OnChunkUnloaded(chunk); + } + + /// + /// Determines if a region should be represented as an all-air chunk. + /// + private bool ShouldBeAllAir(int x, int y, int width, int height) + { + // Check if we have a custom air check delegate + if (_airCheckDelegate != null) + return _airCheckDelegate(x, y, width, height); + + // Check if we have a world generator + if (_worldGenerator != null) + return _worldGenerator.IsRegionEmpty(x, y, width, height); + + // Check if we have a data provider + if (_dataProvider != null) + return _dataProvider.IsEmptyRegion(x, y, width, height); + + // Check height-based air (for sky chunks) + if (_heightMap != null) + return y > _heightMap.GetMaxHeight(x, x + width); + + // Default to non-air to ensure data integrity + return false; + } + + /// + /// Loads or creates a chunk at (x, y) with the given size asynchronously. + /// Returns the loaded or newly created chunk. + /// + public async Task> LoadOrCreateChunkAsync(int x, int y, int width, int height) + { + var key = (x, y); + Task>? task = null; + + // First check if we already have a task + lock (_lock) + { + if (_loadingChunks.TryGetValue(key, out task)) + { + // We found an existing task, no need to create a new one + } + } + + // If we found a task, await it outside the lock + if (task != null) + { + return await task; + } + + // Otherwise, create a new task outside the lock + task = Task.Run(() => LoadOrCreateChunk(x, y, width, height)); + + // Register the task + lock (_lock) + { + // Check again in case another thread created the task while we were creating ours + if (_loadingChunks.TryGetValue(key, out var existingTask)) + { + // Use the existing task instead + task = existingTask; + } + else + { + // Register our task + _loadingChunks[key] = task; + } + } + + try + { + return await task; + } + finally + { + lock (_lock) + { + _loadingChunks.Remove(key); + } + } + } + + /// + /// Unloads (removes) the chunk at (x, y) if loaded. + /// + public bool UnloadChunk(int x, int y) + { + if (_chunks.Remove((x, y), out var chunk)) + { + if (chunk != null) // null check + { + var arr = chunk.GetDataArray(); + if (!chunk.IsAllAir && arr != null) + { + Chunk2D.ReturnArray(arr); + } + ChunkResourceManager.ReleaseChunk(chunk); + ChunkEvents.OnChunkUnloaded(chunk); + } + return true; + } + return false; + } + + /// + /// Unloads (removes) the chunk at (x, y) if loaded asynchronously. + /// + public async Task UnloadChunkAsync(int x, int y) + { + return await Task.Run(() => UnloadChunk(x, y)); + } + + /// + /// Enumerates all loaded chunks. + /// + public IEnumerable> GetAllChunks() => _chunks.Values; + + // IChunkManager interface compatibility + bool IChunkManager.IsChunkLoaded(int x, int y) => IsChunkLoaded(x, y); + IChunk? IChunkManager.GetChunk(int x, int y) => GetChunk(x, y); + IChunk IChunkManager.LoadOrCreateChunk(int x, int y, int width, int height) => LoadOrCreateChunk(x, y, width, height); + bool IChunkManager.UnloadChunk(int x, int y) => UnloadChunk(x, y); + IEnumerable IChunkManager.GetAllChunks() => _chunks.Values; + + /// + /// Sets a custom delegate for determining if a region should be all air. + /// + public void SetAirCheckDelegate(Func airCheckDelegate) + { + _airCheckDelegate = airCheckDelegate; + } + + /// + /// Sets a world generator for determining empty regions. + /// + public void SetWorldGenerator(IWorldGenerator worldGenerator) + { + _worldGenerator = worldGenerator; + } + + /// + /// Sets a data provider for determining empty regions. + /// + public void SetDataProvider(IDataProvider dataProvider) + { + _dataProvider = dataProvider; + } + + /// + /// Sets a height map for determining sky chunks. + /// + public void SetHeightMap(IHeightMap heightMap) + { + _heightMap = heightMap; + } + } + + /// + /// Interface for world generators that can determine if regions are empty. + /// + public interface IWorldGenerator + { + /// + /// Determines if a region is empty (all air). + /// + bool IsRegionEmpty(int x, int y, int width, int height); + } + + /// + /// Interface for data providers that can determine if regions are empty. + /// + public interface IDataProvider + { + /// + /// Determines if a region is empty (all air). + /// + bool IsEmptyRegion(int x, int y, int width, int height); + } + + /// + /// Interface for height maps that can determine the maximum height at a position. + /// + public interface IHeightMap + { + /// + /// Gets the maximum height at a position range. + /// + int GetMaxHeight(int startX, int endX); + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs b/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs new file mode 100644 index 0000000..23013c0 --- /dev/null +++ b/advchksys/src/AdvChkSys/Manager/ChunkManager3D.cs @@ -0,0 +1,244 @@ +#nullable enable +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using System; +using AdvChkSys.Chunk; +using AdvChkSys.Constraints; +using AdvChkSys.Events; +using AdvChkSys.Interfaces; +using AdvChkSys.Resources; +using AdvChkSys.Util; + +namespace AdvChkSys.Manager +{ + /// + /// Manages 3D chunks in memory. Handles loading, unloading, and access. + /// + public class ChunkManager3D : IChunkManager + { + private readonly LRUCache<(int, int, int), Chunk3D> _chunks; + private readonly WorldConstraints? _constraints; + private readonly int _capacity; + + /// + /// Delegate for custom air check logic + /// + private Func? _airCheckDelegate; + + /// + /// World generator for determining empty regions + /// + private IWorldGenerator3D? _worldGenerator; + + /// + /// Data provider for determining empty regions + /// + private IDataProvider3D? _dataProvider; + + /// + /// Height map for determining sky chunks + /// + private IHeightMap3D? _heightMap; + + /// + /// Initializes a new instance of the 3D chunk manager. + /// + /// Optional world constraints + /// Maximum number of chunks to keep in memory + public ChunkManager3D(WorldConstraints? constraints = null, int capacity = 4096) + { + _constraints = constraints; + _capacity = capacity; + _chunks = new LRUCache<(int, int, int), Chunk3D>(_capacity); + } + + /// + /// Returns true if the chunk at (x, y, z) is loaded. + /// + public bool IsChunkLoaded(int x, int y, int z) => _chunks.TryGet((x, y, z), out _); + + /// + /// Gets the chunk at (x, y, z) if loaded, or null if not. + /// + public Chunk3D? GetChunk(int x, int y, int z) + { + _chunks.TryGet((x, y, z), out var chunk); + return chunk; + } + + /// + /// Determines if a region should be represented as an all-air chunk. + /// + private bool ShouldBeAllAir(int x, int y, int z, int width, int height, int depth) + { + // Check if we have a custom air check delegate + if (_airCheckDelegate != null) + return _airCheckDelegate(x, y, z, width, height, depth); + + // Check if we have a world generator + if (_worldGenerator != null) + return _worldGenerator.IsRegionEmpty(x, y, z, width, height, depth); + + // Check if we have a data provider + if (_dataProvider != null) + return _dataProvider.IsEmptyRegion(x, y, z, width, height, depth); + + // Check height-based air (for sky chunks) + if (_heightMap != null) + return y > _heightMap.GetMaxHeight(x, z, x + width, z + depth); + + // Default to non-air to ensure data integrity + return false; + } + + /// + /// Loads or creates a chunk at (x, y, z) with the given size. + /// Returns the loaded or newly created chunk. + /// + public Chunk3D LoadOrCreateChunk(int x, int y, int z, int width, int height, int depth) + { + if (_constraints != null) + { + if (!_constraints.IsWithinBounds(x, y)) + throw new InvalidOperationException("Chunk coordinates out of bounds."); + if (!_constraints.IsWithinChunkLimit(_chunks.Count + 1)) + throw new InvalidOperationException("Chunk limit exceeded."); + } + + if (!_chunks.TryGet((x, y, z), out var chunk)) + { + // If this region is all air, use the singleton + if (ShouldBeAllAir(x, y, z, width, height, depth)) + chunk = Chunk3D.AllAir(x, y, z, width, height, depth); + else + chunk = new Chunk3D(x, y, z, width, height, depth); + + _chunks.Add((x, y, z), chunk, OnChunkEvicted); + ChunkResourceManager.AllocateChunk(chunk); + ChunkEvents.OnChunkLoaded(chunk); + } + return chunk; + } + + // Note: This method may cause trimming warnings when publishing as a trimmed app + private void OnChunkEvicted((int, int, int) key, Chunk3D chunk) + { + if (chunk == null) return; + + // Instead of using reflection, use the GetDataArray method if available + T[,,]? arr = chunk.GetDataArray(); + + // If GetDataArray returned null, fall back to reflection + if (arr == null) + { + var arrField = chunk.GetType().GetField("_data", BindingFlags.NonPublic | BindingFlags.Instance); + if (arrField != null) + { + arr = arrField.GetValue(chunk) as T[,,]; + } + } + + if (!chunk.IsAllAir && arr != null) + { + Chunk3D.ReturnArray(arr); + } + + ChunkResourceManager.ReleaseChunk(chunk); + ChunkEvents.OnChunkUnloaded(chunk); + } + + /// + /// Unloads (removes) the chunk at (x, y, z) if loaded. + /// + public bool UnloadChunk(int x, int y, int z) + { + if (_chunks.Remove((x, y, z), out var chunk) && chunk != null) // null check + { + ChunkResourceManager.ReleaseChunk(chunk); + ChunkEvents.OnChunkUnloaded(chunk); + return true; + } + return false; + } + + /// + /// Enumerates all loaded chunks. + /// + public IEnumerable> GetAllChunks() => _chunks.Values; + + // IChunkManager interface compatibility (for 2D interface) + bool IChunkManager.IsChunkLoaded(int x, int y) => _chunks.TryGet((x, y, 0), out _); + IChunk? IChunkManager.GetChunk(int x, int y) => _chunks.TryGet((x, y, 0), out var chunk) ? chunk : null; + IChunk IChunkManager.LoadOrCreateChunk(int x, int y, int width, int height) => + LoadOrCreateChunk(x, y, 0, width, height, 1); + bool IChunkManager.UnloadChunk(int x, int y) => UnloadChunk(x, y, 0); + IEnumerable IChunkManager.GetAllChunks() => _chunks.Values; + + /// + /// Sets a custom delegate for determining if a region should be all air. + /// + public void SetAirCheckDelegate(Func airCheckDelegate) + { + _airCheckDelegate = airCheckDelegate; + } + + /// + /// Sets a world generator for determining empty regions. + /// + public void SetWorldGenerator(IWorldGenerator3D worldGenerator) + { + _worldGenerator = worldGenerator; + } + + /// + /// Sets a data provider for determining empty regions. + /// + public void SetDataProvider(IDataProvider3D dataProvider) + { + _dataProvider = dataProvider; + } + + /// + /// Sets a height map for determining sky chunks. + /// + public void SetHeightMap(IHeightMap3D heightMap) + { + _heightMap = heightMap; + } + } + + /// + /// Interface for 3D world generators that can determine if regions are empty. + /// + public interface IWorldGenerator3D + { + /// + /// Determines if a region is empty (all air). + /// + bool IsRegionEmpty(int x, int y, int z, int width, int height, int depth); + } + + /// + /// Interface for 3D data providers that can determine if regions are empty. + /// + public interface IDataProvider3D + { + /// + /// Determines if a region is empty (all air). + /// + bool IsEmptyRegion(int x, int y, int z, int width, int height, int depth); + } + + /// + /// Interface for 3D height maps that can determine the maximum height at a position. + /// + public interface IHeightMap3D + { + /// + /// Gets the maximum height at a position range. + /// + int GetMaxHeight(int startX, int startZ, int endX, int endZ); + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs b/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs new file mode 100644 index 0000000..227483d --- /dev/null +++ b/advchksys/src/AdvChkSys/Resources/ChunkResourceManager.cs @@ -0,0 +1,75 @@ +#nullable enable +using AdvChkSys.Interfaces; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace AdvChkSys.Resources +{ + /// + /// Manages allocation and release of chunk resources in memory. + /// Can be extended to track resource usage, pooling, or implement custom memory strategies. + /// + public static class ChunkResourceManager + { + // Example: Track allocated chunks (for diagnostics, pooling, or resource limits) + private static readonly ConcurrentDictionary _allocatedChunks = new(); + private static readonly object _lock = new(); + private static readonly HashSet _activeChunks = new(); + + /// + /// Called when a chunk is allocated/loaded into memory. + /// Tracks the chunk and can be extended for pooling or resource limits. + /// + public static void AllocateChunk(IChunk chunk) + { + lock (_lock) + { + _activeChunks.Add(chunk); + _allocatedChunks[chunk] = DateTime.UtcNow; + } + } + + /// + /// Called when a chunk is released/unloaded from memory. + /// Removes the chunk from tracking and can be extended for pooling or cleanup. + /// + public static void ReleaseChunk(IChunk chunk) + { + lock (_lock) + { + _activeChunks.Remove(chunk); + _allocatedChunks.TryRemove(chunk, out _); + } + } + + /// + /// Gets the current number of allocated chunks. + /// + public static int AllocatedChunkCount => _allocatedChunks.Count; + + /// + /// Clears all tracked chunks (for diagnostics or shutdown). + /// + public static void Clear() + { + _allocatedChunks.Clear(); + lock (_lock) + { + _activeChunks.Clear(); + } + } + + /// + /// Gets the current count of active chunks being tracked by the resource manager. + /// + /// The number of active chunks + public static int GetActiveChunkCount() + { + lock (_lock) + { + return _activeChunks.Count; + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs b/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs new file mode 100644 index 0000000..d615a1c --- /dev/null +++ b/advchksys/src/AdvChkSys/Serialization/ChunkSerializer.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using AdvChkSys.Chunk; + +namespace AdvChkSys.Serialization +{ + /// + /// Provides serialization and deserialization for chunk instances. + /// Supports Chunk2D T and Chunk3D T with primitive types (e.g., byte, int, float). + /// . + public static class ChunkSerializer + { + // -------- 2D -------- + + /// + /// Serializes a 2D chunk to a byte array. + /// + /// The type of data stored in the chunk + /// The chunk to serialize + /// Serialized byte array + public static byte[] Serialize2D(Chunk2D chunk) where T : struct + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(chunk.X); + writer.Write(chunk.Y); + writer.Write(chunk.Width); + writer.Write(chunk.Height); + + writer.Write(chunk.Metadata.Count); + foreach (var kvp in chunk.Metadata) + { + writer.Write(kvp.Key); + writer.Write(kvp.Value?.ToString() ?? ""); + } + + for (int x = 0; x < chunk.Width; x++) + for (int y = 0; y < chunk.Height; y++) + WritePrimitive(writer, chunk[x, y]); + + writer.Flush(); + return ms.ToArray(); + } + + /// + /// Deserializes a byte array into a 2D chunk. + /// + /// The type of data stored in the chunk + /// The serialized chunk data + /// Deserialized chunk + public static Chunk2D Deserialize2D(byte[] data) where T : struct + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + int x = reader.ReadInt32(); + int y = reader.ReadInt32(); + int width = reader.ReadInt32(); + int height = reader.ReadInt32(); + + var chunk = new Chunk2D(x, y, width, height); + + int metaCount = reader.ReadInt32(); + for (int i = 0; i < metaCount; i++) + { + string key = reader.ReadString(); + string value = reader.ReadString(); + chunk.Metadata[key] = value; + } + + for (int ix = 0; ix < width; ix++) + for (int iy = 0; iy < height; iy++) + chunk[ix, iy] = ReadPrimitive(reader); + + return chunk; + } + + // -------- 3D -------- + + /// + /// Serializes a 3D chunk to a byte array. + /// + /// The type of data stored in the chunk + /// The chunk to serialize + /// Serialized byte array + public static byte[] Serialize3D(Chunk3D chunk) where T : struct + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + writer.Write(chunk.X); + writer.Write(chunk.Y); + writer.Write(chunk.Z); + writer.Write(chunk.Width); + writer.Write(chunk.Height); + writer.Write(chunk.Depth); + + writer.Write(chunk.Metadata.Count); + foreach (var kvp in chunk.Metadata) + { + writer.Write(kvp.Key); + writer.Write(kvp.Value?.ToString() ?? ""); + } + + for (int x = 0; x < chunk.Width; x++) + for (int y = 0; y < chunk.Height; y++) + for (int z = 0; z < chunk.Depth; z++) + WritePrimitive(writer, chunk[x, y, z]); + + writer.Flush(); + return ms.ToArray(); + } + + /// + /// Deserializes a byte array into a 3D chunk. + /// + /// The type of data stored in the chunk + /// The serialized chunk data + /// Deserialized chunk + public static Chunk3D Deserialize3D(byte[] data) where T : struct + { + using var ms = new MemoryStream(data); + using var reader = new BinaryReader(ms); + + int x = reader.ReadInt32(); + int y = reader.ReadInt32(); + int z = reader.ReadInt32(); + int width = reader.ReadInt32(); + int height = reader.ReadInt32(); + int depth = reader.ReadInt32(); + + var chunk = new Chunk3D(x, y, z, width, height, depth); + + int metaCount = reader.ReadInt32(); + for (int i = 0; i < metaCount; i++) + { + string key = reader.ReadString(); + string value = reader.ReadString(); + chunk.Metadata[key] = value; + } + + for (int ix = 0; ix < width; ix++) + for (int iy = 0; iy < height; iy++) + for (int iz = 0; iz < depth; iz++) + chunk[ix, iy, iz] = ReadPrimitive(reader); + + return chunk; + } + + // -------- Helpers -------- + + private static void WritePrimitive(BinaryWriter writer, T value) where T : struct + { + if (typeof(T) == typeof(byte)) + writer.Write((byte)(object)value); + else if (typeof(T) == typeof(int)) + writer.Write((int)(object)value); + else if (typeof(T) == typeof(float)) + writer.Write((float)(object)value); + else if (typeof(T) == typeof(double)) + writer.Write((double)(object)value); + else if (typeof(T) == typeof(short)) + writer.Write((short)(object)value); + else if (typeof(T) == typeof(long)) + writer.Write((long)(object)value); + else if (typeof(T) == typeof(bool)) + writer.Write((bool)(object)value); + else + throw new NotSupportedException($"Type {typeof(T)} is not supported for serialization."); + } + + private static T ReadPrimitive(BinaryReader reader) where T : struct + { + if (typeof(T) == typeof(byte)) + return (T)(object)reader.ReadByte(); + else if (typeof(T) == typeof(int)) + return (T)(object)reader.ReadInt32(); + else if (typeof(T) == typeof(float)) + return (T)(object)reader.ReadSingle(); + else if (typeof(T) == typeof(double)) + return (T)(object)reader.ReadDouble(); + else if (typeof(T) == typeof(short)) + return (T)(object)reader.ReadInt16(); + else if (typeof(T) == typeof(long)) + return (T)(object)reader.ReadInt64(); + else if (typeof(T) == typeof(bool)) + return (T)(object)reader.ReadBoolean(); + else + throw new NotSupportedException($"Type {typeof(T)} is not supported for deserialization."); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs b/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs new file mode 100644 index 0000000..c37ec4e --- /dev/null +++ b/advchksys/src/AdvChkSys/Spatial/ChunkExtensions.cs @@ -0,0 +1,40 @@ +#nullable enable +using AdvChkSys.Chunk; + +namespace AdvChkSys.Spatial +{ + /// + /// Provides extension methods for chunks to work with spatial indexing. + /// + public static class ChunkExtensions + { + /// + /// Converts a Chunk3D to an IChunk3D. + /// + public static IChunk3D AsIChunk3D(this Chunk3D chunk) + { + return new Chunk3DAdapter(chunk); + } + + /// + /// Adapter to make Chunk3D implement IChunk3D. + /// + private class Chunk3DAdapter : IChunk3D + { + private readonly Chunk3D _chunk; + + public Chunk3DAdapter(Chunk3D chunk) + { + _chunk = chunk; + } + + public int X => _chunk.X; + public int Y => _chunk.Y; + public int Z => _chunk.Z; + public int Width => _chunk.Width; + public int Height => _chunk.Height; + public int Depth => _chunk.Depth; + public System.Collections.Generic.Dictionary Metadata => _chunk.Metadata; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs b/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs new file mode 100644 index 0000000..53c02f5 --- /dev/null +++ b/advchksys/src/AdvChkSys/Spatial/SpatialChunkIndex.cs @@ -0,0 +1,1225 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using AdvChkSys.Interfaces; +using AdvChkSys.Threading; + + +namespace AdvChkSys.Spatial +{ + /// + /// Provides spatial indexing for efficient region and distance queries. + /// + public class SpatialChunkIndex where T : IChunk + { + // Grid-based spatial index for fast lookups + private readonly Dictionary<(int, int), HashSet> _grid = new(); + + // All chunks in the index for iteration + private readonly HashSet _allChunks = new(); + + // Lock for thread safety + private readonly object _lock = new(); + + /// + /// Adds a chunk to the spatial index. + /// + /// The chunk to add + public void AddChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Add to all chunks set + _allChunks.Add(chunk); + + // Add to grid cells + var key = (chunk.X, chunk.Y); + if (!_grid.TryGetValue(key, out var chunks)) + { + chunks = new HashSet(); + _grid[key] = chunks; + } + chunks.Add(chunk); + } + } + + /// + /// Removes a chunk from the spatial index. + /// + /// The chunk to remove + /// True if the chunk was found and removed, false otherwise + public bool RemoveChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Remove from all chunks set + bool removed = _allChunks.Remove(chunk); + + // Remove from grid cells + var key = (chunk.X, chunk.Y); + if (_grid.TryGetValue(key, out var chunks)) + { + chunks.Remove(chunk); + if (chunks.Count == 0) + { + _grid.Remove(key); + } + } + + return removed; + } + } + + /// + /// Finds all chunks within a rectangular region. + /// + /// Minimum X coordinate + /// Minimum Y coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// All chunks that intersect with the region + public IEnumerable FindChunksInRegion(int minX, int minY, int maxX, int maxY) + { + lock (_lock) + { + var result = new HashSet(); + + // Check each grid cell that overlaps with the region + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + var key = (x, y); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + result.Add(chunk); + } + } + } + } + + return result; + } + } + + /// + /// Finds all chunks within a specified distance from a point. + /// + /// Center X coordinate + /// Center Y coordinate + /// Radius to search within + /// All chunks that are within the radius of the point + public IEnumerable FindChunksInRadius(int centerX, int centerY, float radius) + { + lock (_lock) + { + var result = new HashSet(); + float radiusSquared = radius * radius; + + // Calculate the bounding box of the circle + int minX = (int)(centerX - radius); + int minY = (int)(centerY - radius); + int maxX = (int)(centerX + radius); + int maxY = (int)(centerY + radius); + + // Check each grid cell that might overlap with the circle + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + var key = (x, y); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + // Calculate distance from chunk center to query center + float dx = chunk.X - centerX; + float dy = chunk.Y - centerY; + float distanceSquared = dx * dx + dy * dy; + + // Check if the chunk is within the radius + if (distanceSquared <= radiusSquared) + { + result.Add(chunk); + } + } + } + } + } + + return result; + } + } + + /// + /// Finds the nearest chunk to a point. + /// + /// X coordinate + /// Y coordinate + /// The nearest chunk, or null if no chunks are in the index + public T? FindNearestChunk(int x, int y) + { + lock (_lock) + { + if (_allChunks.Count == 0) + return default; + + T? nearest = default; + float nearestDistanceSquared = float.MaxValue; + + foreach (var chunk in _allChunks) + { + float dx = chunk.X - x; + float dy = chunk.Y - y; + float distanceSquared = dx * dx + dy * dy; + + if (distanceSquared < nearestDistanceSquared) + { + nearestDistanceSquared = distanceSquared; + nearest = chunk; + } + } + + return nearest; + } + } + + /// + /// Finds all chunks that contain a point. + /// + /// X coordinate + /// Y coordinate + /// All chunks that contain the point + public IEnumerable FindChunksContainingPoint(int x, int y) + { + lock (_lock) + { + var result = new HashSet(); + + // Get the grid cell for this point + var key = (x, y); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + // Check if the point is within the chunk bounds + if (x >= chunk.X && x < chunk.X + chunk.Width && + y >= chunk.Y && y < chunk.Y + chunk.Height) + { + result.Add(chunk); + } + } + } + + return result; + } + } + + /// + /// Gets all chunks in the index. + /// + public IEnumerable GetAllChunks() + { + lock (_lock) + { + return _allChunks.ToList(); + } + } + + /// + /// Clears all chunks from the index. + /// + public void Clear() + { + lock (_lock) + { + _allChunks.Clear(); + _grid.Clear(); + } + } + + /// + /// Gets the number of chunks in the index. + /// + public int Count + { + get + { + lock (_lock) + { + return _allChunks.Count; + } + } + } + + /// + /// Checks if a chunk is in the index. + /// + /// The chunk to check + /// True if the chunk is in the index, false otherwise + public bool Contains(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + return _allChunks.Contains(chunk); + } + } + + /// + /// Updates the position of a chunk in the spatial index. + /// + /// The chunk to update + /// The old X coordinate + /// The old Y coordinate + /// True if the chunk was found and updated, false otherwise + public bool UpdateChunkPosition(T chunk, int oldX, int oldY) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Remove from old grid cell + var oldKey = (oldX, oldY); + bool removed = false; + + if (_grid.TryGetValue(oldKey, out var chunks)) + { + removed = chunks.Remove(chunk); + if (chunks.Count == 0) + { + _grid.Remove(oldKey); + } + } + + if (!removed) + return false; + + // Add to new grid cell + var newKey = (chunk.X, chunk.Y); + if (!_grid.TryGetValue(newKey, out var newChunks)) + { + newChunks = new HashSet(); + _grid[newKey] = newChunks; + } + newChunks.Add(chunk); + + return true; + } + } + + /// + /// Performs a spatial query using a custom filter. + /// + /// The filter function to apply to each chunk + /// All chunks that pass the filter + public IEnumerable Query(Func filter) + { + if (filter == null) + throw new ArgumentNullException(nameof(filter)); + + lock (_lock) + { + return _allChunks.Where(filter).ToList(); + } + } + + /// + /// Finds all chunks that intersect with a given chunk. + /// + /// The chunk to check for intersections + /// All chunks that intersect with the given chunk + public IEnumerable FindIntersectingChunks(IChunk chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + return FindChunksInRegion( + chunk.X, + chunk.Y, + chunk.X + chunk.Width - 1, + chunk.Y + chunk.Height - 1); + } + + /// + /// Finds all chunks that are neighbors of a given chunk. + /// + /// The chunk to find neighbors for + /// Whether to include diagonal neighbors + /// All neighboring chunks + public IEnumerable FindNeighbors(IChunk chunk, bool includeDiagonals = false) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + var result = new HashSet(); + + // Check the four adjacent cells + CheckNeighbor(chunk.X - 1, chunk.Y, result); + CheckNeighbor(chunk.X + 1, chunk.Y, result); + CheckNeighbor(chunk.X, chunk.Y - 1, result); + CheckNeighbor(chunk.X, chunk.Y + 1, result); + + // Check diagonal cells if requested + if (includeDiagonals) + { + CheckNeighbor(chunk.X - 1, chunk.Y - 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y - 1, result); + CheckNeighbor(chunk.X - 1, chunk.Y + 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y + 1, result); + } + + return result; + } + } + + /// + /// Helper method to check for chunks at a specific grid cell. + /// + private void CheckNeighbor(int x, int y, HashSet result) + { + var key = (x, y); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + result.Add(chunk); + } + } + } + + /// + /// Performs a spatial operation on chunks in parallel. + /// + /// The operation to perform on each chunk + /// Optional filter to apply before processing + /// Maximum degree of parallelism (null for default) + /// A task that completes when all operations are done + public Task ProcessChunksParallelAsync( + Action operation, + Func? filter = null, + int? maxDegreeOfParallelism = null) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + IEnumerable chunksToProcess; + + lock (_lock) + { + chunksToProcess = filter != null + ? _allChunks.Where(filter).ToList() + : _allChunks.ToList(); + } + + return ChunkTaskScheduler.RunBatchParallelAsync( + chunksToProcess.Select(chunk => new Action(() => operation(chunk))).ToArray(), + maxDegreeOfParallelism); + } + + /// + /// Finds all chunks in a region and performs an operation on them in parallel. + /// + /// Minimum X coordinate + /// Minimum Y coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// The operation to perform on each chunk + /// Maximum degree of parallelism (null for default) + /// A task that completes when all operations are done + public Task ProcessRegionParallelAsync( + int minX, int minY, int maxX, int maxY, + Action operation, + int? maxDegreeOfParallelism = null) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + var chunksInRegion = FindChunksInRegion(minX, minY, maxX, maxY).ToList(); + + return ChunkTaskScheduler.RunBatchParallelAsync( + chunksInRegion.Select(chunk => new Action(() => operation(chunk))).ToArray(), + maxDegreeOfParallelism); + } + + /// + /// Creates a quadtree-based spatial index for more efficient queries. + /// + /// Minimum X coordinate of the world + /// Minimum Y coordinate of the world + /// Maximum X coordinate of the world + /// Maximum Y coordinate of the world + /// A new quadtree-based spatial index + public QuadtreeSpatialIndex CreateQuadtreeIndex(int minX, int minY, int maxX, int maxY) + { + var quadtree = new QuadtreeSpatialIndex(minX, minY, maxX, maxY); + + lock (_lock) + { + foreach (var chunk in _allChunks) + { + quadtree.AddChunk(chunk); + } + } + + return quadtree; + } + } + + /// + /// Provides a quadtree-based spatial index for more efficient region queries. + /// + public class QuadtreeSpatialIndex where T : IChunk + { + private readonly QuadtreeNode _root; + private readonly HashSet _allChunks = new(); + private readonly object _lock = new(); + + /// + /// Initializes a new instance of the QuadtreeSpatialIndex class. + /// + /// Minimum X coordinate of the world + /// Minimum Y coordinate of the world + /// Maximum X coordinate of the world + /// Maximum Y coordinate of the world + public QuadtreeSpatialIndex(int minX, int minY, int maxX, int maxY) + { + _root = new QuadtreeNode(minX, minY, maxX, maxY); + } + + /// + /// Adds a chunk to the quadtree. + /// + /// The chunk to add + public void AddChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + _allChunks.Add(chunk); + _root.Insert(chunk); + } + } + + /// + /// Removes a chunk from the quadtree. + /// + /// The chunk to remove + /// True if the chunk was found and removed, false otherwise + public bool RemoveChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + bool removed = _allChunks.Remove(chunk); + if (removed) + { + _root.Remove(chunk); + } + return removed; + } + } + + /// + /// Finds all chunks within a rectangular region. + /// + /// Minimum X coordinate + /// Minimum Y coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// All chunks that intersect with the region + public IEnumerable FindChunksInRegion(int minX, int minY, int maxX, int maxY) + { + lock (_lock) + { + var result = new HashSet(); + _root.Query(minX, minY, maxX, maxY, result); + return result; + } + } + + /// + /// Gets all chunks in the quadtree. + /// + public IEnumerable GetAllChunks() + { + lock (_lock) + { + return _allChunks.ToList(); + } + } + + /// + /// Clears all chunks from the quadtree. + /// + public void Clear() + { + lock (_lock) + { + _allChunks.Clear(); + _root.Clear(); + } + } + + /// + /// Gets the number of chunks in the quadtree. + /// + public int Count + { + get + { + lock (_lock) + { + return _allChunks.Count; + } + } + } + + /// + /// Represents a node in the quadtree. + /// + private class QuadtreeNode + { + // Boundary of this node + private readonly int _minX, _minY, _maxX, _maxY; + + // Children nodes (NW, NE, SW, SE) + private QuadtreeNode? _northWest; + private QuadtreeNode? _northEast; + private QuadtreeNode? _southWest; + private QuadtreeNode? _southEast; + + // Chunks in this node + private readonly HashSet _chunks = new(); + + // Maximum number of chunks before splitting + private const int MAX_CHUNKS = 8; + + /// + /// Initializes a new instance of the QuadtreeNode class. + /// + public QuadtreeNode(int minX, int minY, int maxX, int maxY) + { + _minX = minX; + _minY = minY; + _maxX = maxX; + _maxY = maxY; + } + + /// + /// Inserts a chunk into the quadtree. + /// + public void Insert(T chunk) + { + // Check if the chunk is within this node's boundary + if (!Intersects(chunk)) + return; + + // If we haven't split yet and have room, add the chunk to this node + if (_northWest == null && _chunks.Count < MAX_CHUNKS) + { + _chunks.Add(chunk); + return; + } + + // If we haven't split yet but need to, split the node + if (_northWest == null) + { + Split(); + } + + // Try to insert the chunk into the children + _northWest?.Insert(chunk); + _northEast?.Insert(chunk); + _southWest?.Insert(chunk); + _southEast?.Insert(chunk); + } + + /// + /// Removes a chunk from the quadtree. + /// + public bool Remove(T chunk) + { + // Check if the chunk is within this node's boundary + if (!Intersects(chunk)) + return false; + + // Try to remove from this node + bool removed = _chunks.Remove(chunk); + + // If we have children, try to remove from them too + if (_northWest != null) + { + removed |= _northWest.Remove(chunk); + removed |= _northEast!.Remove(chunk); + removed |= _southWest!.Remove(chunk); + removed |= _southEast!.Remove(chunk); + } + + return removed; + } + + /// + /// Queries the quadtree for chunks in a region. + /// + public void Query(int minX, int minY, int maxX, int maxY, HashSet result) + { + // Check if the query region intersects this node + if (maxX < _minX || minX > _maxX || maxY < _minY || minY > _maxY) + return; + + // Add chunks from this node that intersect the query region + foreach (var chunk in _chunks) + { + if (chunk.X <= maxX && chunk.X + chunk.Width >= minX && + chunk.Y <= maxY && chunk.Y + chunk.Height >= minY) + { + result.Add(chunk); + } + } + + // If we have children, query them too + if (_northWest != null) + { + _northWest.Query(minX, minY, maxX, maxY, result); + _northEast!.Query(minX, minY, maxX, maxY, result); + _southWest!.Query(minX, minY, maxX, maxY, result); + _southEast!.Query(minX, minY, maxX, maxY, result); + } + } + + /// + /// Splits this node into four children. + /// + private void Split() + { + int midX = (_minX + _maxX) / 2; + int midY = (_minY + _maxY) / 2; + + // Don't split if the node is too small + if (midX == _minX || midY == _minY) + return; + + _northWest = new QuadtreeNode(_minX, _minY, midX, midY); + _northEast = new QuadtreeNode(midX, _minY, _maxX, midY); + _southWest = new QuadtreeNode(_minX, midY, midX, _maxY); + _southEast = new QuadtreeNode(midX, midY, _maxX, _maxY); + + // Redistribute chunks to children + var chunksToRedistribute = new List(_chunks); + _chunks.Clear(); + + foreach (var chunk in chunksToRedistribute) + { + Insert(chunk); + } + } + + /// + /// Checks if a chunk intersects with this node's boundary. + /// + private bool Intersects(T chunk) + { + return chunk.X <= _maxX && + chunk.X + chunk.Width >= _minX && + chunk.Y <= _maxY && + chunk.Y + chunk.Height >= _minY; + } + + /// + /// Clears all chunks from this node and its children. + /// + public void Clear() + { + _chunks.Clear(); + + if (_northWest != null) + { + _northWest.Clear(); + _northEast!.Clear(); + _southWest!.Clear(); + _southEast!.Clear(); + + _northWest = null; + _northEast = null; + _southWest = null; + _southEast = null; + } + } + } + } + + /// + /// Provides a spatial index for 3D chunks. + /// + public class SpatialChunkIndex3D where T : IChunk3D + { + // Grid-based spatial index for fast lookups + private readonly Dictionary<(int, int, int), HashSet> _grid = new(); + + // All chunks in the index for iteration + private readonly HashSet _allChunks = new(); + + // Lock for thread safety + private readonly object _lock = new(); + + /// + /// Adds a chunk to the spatial index. + /// + /// The chunk to add + public void AddChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Add to all chunks set + _allChunks.Add(chunk); + + // Add to grid cells + var key = (chunk.X, chunk.Y, chunk.Z); + if (!_grid.TryGetValue(key, out var chunks)) + { + chunks = new HashSet(); + _grid[key] = chunks; + } + chunks.Add(chunk); + } + } + + /// + /// Removes a chunk from the spatial index. + /// + /// The chunk to remove + /// True if the chunk was found and removed, false otherwise + public bool RemoveChunk(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Remove from all chunks set + bool removed = _allChunks.Remove(chunk); + + // Remove from grid cells + var key = (chunk.X, chunk.Y, chunk.Z); + if (_grid.TryGetValue(key, out var chunks)) + { + chunks.Remove(chunk); + if (chunks.Count == 0) + { + _grid.Remove(key); + } + } + + return removed; + } + } + + /// + /// Finds all chunks within a rectangular region. + /// + /// Minimum X coordinate + /// Minimum Y coordinate + /// Minimum Z coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// Maximum Z coordinate + /// All chunks that intersect with the region + public IEnumerable FindChunksInRegion(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) + { + lock (_lock) + { + var result = new HashSet(); + + // Check each grid cell that overlaps with the region + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + for (int z = minZ; z <= maxZ; z++) + { + var key = (x, y, z); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + result.Add(chunk); + } + } + } + } + } + + return result; + } + } + + /// + /// Finds all chunks within a specified distance from a point. + /// + /// Center X coordinate + /// Center Y coordinate + /// Center Z coordinate + /// Radius to search within + /// All chunks that are within the radius of the point + public IEnumerable FindChunksInRadius(int centerX, int centerY, int centerZ, float radius) + { + lock (_lock) + { + var result = new HashSet(); + float radiusSquared = radius * radius; + + // Calculate the bounding box of the sphere + int minX = (int)(centerX - radius); + int minY = (int)(centerY - radius); + int minZ = (int)(centerZ - radius); + int maxX = (int)(centerX + radius); + int maxY = (int)(centerY + radius); + int maxZ = (int)(centerZ + radius); + + // Check each grid cell that might overlap with the sphere + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + for (int z = minZ; z <= maxZ; z++) + { + var key = (x, y, z); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + // Calculate distance from chunk center to query center + float dx = chunk.X - centerX; + float dy = chunk.Y - centerY; + float dz = chunk.Z - centerZ; + float distanceSquared = dx * dx + dy * dy + dz * dz; + + // Check if the chunk is within the radius + if (distanceSquared <= radiusSquared) + { + result.Add(chunk); + } + } + } + } + } + } + + return result; + } + } + + /// + /// Finds the nearest chunk to a point. + /// + /// X coordinate + /// Y coordinate + /// Z coordinate + /// The nearest chunk, or null if no chunks are in the index + public T? FindNearestChunk(int x, int y, int z) + { + lock (_lock) + { + if (_allChunks.Count == 0) + return default; + + T? nearest = default; + float nearestDistanceSquared = float.MaxValue; + + foreach (var chunk in _allChunks) + { + float dx = chunk.X - x; + float dy = chunk.Y - y; + float dz = chunk.Z - z; + float distanceSquared = dx * dx + dy * dy + dz * dz; + + if (distanceSquared < nearestDistanceSquared) + { + nearestDistanceSquared = distanceSquared; + nearest = chunk; + } + } + + return nearest; + } + } + + /// + /// Gets all chunks in the index. + /// + public IEnumerable GetAllChunks() + { + lock (_lock) + { + return _allChunks.ToList(); + } + } + + /// + /// Clears all chunks from the index. + /// + public void Clear() + { + lock (_lock) + { + _allChunks.Clear(); + _grid.Clear(); + } + } + + /// + /// Gets the number of chunks in the index. + /// + public int Count + { + get + { + lock (_lock) + { + return _allChunks.Count; + } + } + } + + /// + /// Checks if a chunk is in the index. + /// + /// The chunk to check + /// True if the chunk is in the index, false otherwise + public bool Contains(T chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + return _allChunks.Contains(chunk); + } + } + + /// + /// Updates the position of a chunk in the spatial index. + /// + /// The chunk to update + /// The old X coordinate + /// The old Y coordinate + /// The old Z coordinate + /// True if the chunk was found and updated, false otherwise + public bool UpdateChunkPosition(T chunk, int oldX, int oldY, int oldZ) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + // Remove from old grid cell + var oldKey = (oldX, oldY, oldZ); + bool removed = false; + + if (_grid.TryGetValue(oldKey, out var chunks)) + { + removed = chunks.Remove(chunk); + if (chunks.Count == 0) + { + _grid.Remove(oldKey); + } + } + + if (!removed) + return false; + + // Add to new grid cell + var newKey = (chunk.X, chunk.Y, chunk.Z); + if (!_grid.TryGetValue(newKey, out var newChunks)) + { + newChunks = new HashSet(); + _grid[newKey] = newChunks; + } + newChunks.Add(chunk); + + return true; + } + } + + /// + /// Performs a spatial query using a custom filter. + /// + /// The filter function to apply to each chunk + /// All chunks that pass the filter + public IEnumerable Query(Func filter) + { + if (filter == null) + throw new ArgumentNullException(nameof(filter)); + + lock (_lock) + { + return _allChunks.Where(filter).ToList(); + } + } + + /// + /// Finds all chunks that intersect with a given chunk. + /// + /// The chunk to check for intersections + /// All chunks that intersect with the given chunk + public IEnumerable FindIntersectingChunks(IChunk3D chunk) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + return FindChunksInRegion( + chunk.X, + chunk.Y, + chunk.Z, + chunk.X + chunk.Width - 1, + chunk.Y + chunk.Height - 1, + chunk.Z + chunk.Depth - 1); + } + + /// + /// Finds all chunks that are neighbors of a given chunk. + /// + /// The chunk to find neighbors for + /// Whether to include diagonal neighbors + /// Whether to include edge neighbors + /// All neighboring chunks + public IEnumerable FindNeighbors(IChunk3D chunk, bool includeDiagonals = false, bool includeEdges = true) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + lock (_lock) + { + var result = new HashSet(); + + // Check the six adjacent cells (faces) + CheckNeighbor(chunk.X - 1, chunk.Y, chunk.Z, result); + CheckNeighbor(chunk.X + 1, chunk.Y, chunk.Z, result); + CheckNeighbor(chunk.X, chunk.Y - 1, chunk.Z, result); + CheckNeighbor(chunk.X, chunk.Y + 1, chunk.Z, result); + CheckNeighbor(chunk.X, chunk.Y, chunk.Z - 1, result); + CheckNeighbor(chunk.X, chunk.Y, chunk.Z + 1, result); + + // Check edge neighbors if requested + if (includeEdges) + { + // X-Y edges + CheckNeighbor(chunk.X - 1, chunk.Y - 1, chunk.Z, result); + CheckNeighbor(chunk.X + 1, chunk.Y - 1, chunk.Z, result); + CheckNeighbor(chunk.X - 1, chunk.Y + 1, chunk.Z, result); + CheckNeighbor(chunk.X + 1, chunk.Y + 1, chunk.Z, result); + + // X-Z edges + CheckNeighbor(chunk.X - 1, chunk.Y, chunk.Z - 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y, chunk.Z - 1, result); + CheckNeighbor(chunk.X - 1, chunk.Y, chunk.Z + 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y, chunk.Z + 1, result); + + // Y-Z edges + CheckNeighbor(chunk.X, chunk.Y - 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X, chunk.Y + 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X, chunk.Y - 1, chunk.Z + 1, result); + CheckNeighbor(chunk.X, chunk.Y + 1, chunk.Z + 1, result); + } + + // Check diagonal corners if requested + if (includeDiagonals) + { + CheckNeighbor(chunk.X - 1, chunk.Y - 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y - 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X - 1, chunk.Y + 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y + 1, chunk.Z - 1, result); + CheckNeighbor(chunk.X - 1, chunk.Y - 1, chunk.Z + 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y - 1, chunk.Z + 1, result); + CheckNeighbor(chunk.X - 1, chunk.Y + 1, chunk.Z + 1, result); + CheckNeighbor(chunk.X + 1, chunk.Y + 1, chunk.Z + 1, result); + } + + return result; + } + } + + /// + /// Helper method to check for chunks at a specific grid cell. + /// + private void CheckNeighbor(int x, int y, int z, HashSet result) + { + var key = (x, y, z); + if (_grid.TryGetValue(key, out var chunks)) + { + foreach (var chunk in chunks) + { + result.Add(chunk); + } + } + } + + /// + /// Performs a spatial operation on chunks in parallel. + /// + /// The operation to perform on each chunk + /// Optional filter to apply before processing + /// Maximum degree of parallelism (null for default) + /// A task that completes when all operations are done + public Task ProcessChunksParallelAsync( + Action operation, + Func? filter = null, + int? maxDegreeOfParallelism = null) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + IEnumerable chunksToProcess; + + lock (_lock) + { + chunksToProcess = filter != null + ? _allChunks.Where(filter).ToList() + : _allChunks.ToList(); + } + + return ChunkTaskScheduler.RunBatchParallelAsync( + chunksToProcess.Select(chunk => new Action(() => operation(chunk))).ToArray(), + maxDegreeOfParallelism); + } + } + + /// + /// Interface for 3D chunks. + /// + public interface IChunk3D : IChunk + { + /// + /// The chunk's Z position in chunk coordinates. + /// + int Z { get; } + + /// + /// The depth of the chunk (in cells/tiles/units). + /// + int Depth { get; } + } +} diff --git a/advchksys/src/AdvChkSys/Threading/ChunkAsyncLock.cs b/advchksys/src/AdvChkSys/Threading/ChunkAsyncLock.cs new file mode 100644 index 0000000..58ac3e1 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkAsyncLock.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Threading +{ + /// + /// Provides asynchronous locking for chunks. + /// + public class ChunkAsyncLock : IDisposable + { + // Lock objects for each chunk + private readonly ConcurrentDictionary _locks = new(); + + // Timer for cleanup + private readonly Timer _cleanupTimer; + + // Last access time for each lock + private readonly ConcurrentDictionary _lastAccessTime = new(); + + // Cleanup interval in minutes + private readonly int _cleanupIntervalMinutes; + + /// + /// Initializes a new instance of the ChunkAsyncLock class. + /// + /// Interval in minutes for cleaning up unused locks + public ChunkAsyncLock(int cleanupIntervalMinutes = 10) + { + _cleanupIntervalMinutes = cleanupIntervalMinutes; + _cleanupTimer = new Timer(CleanupUnusedLocks, null, + TimeSpan.FromMinutes(cleanupIntervalMinutes), + TimeSpan.FromMinutes(cleanupIntervalMinutes)); + } + + /// + /// Acquires a lock on a chunk asynchronously. + /// + /// The chunk to lock + /// Cancellation token + /// A disposable that releases the lock when disposed + public async Task LockAsync(IChunk chunk, CancellationToken cancellationToken = default) + { + var semaphore = _locks.GetOrAdd(chunk, _ => new SemaphoreSlim(1, 1)); + _lastAccessTime[chunk] = DateTime.UtcNow; + + try + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // If canceled, check if we need to clean up the semaphore + if (semaphore.CurrentCount == 1) + { + // No one is waiting, try to remove + _locks.TryRemove(chunk, out _); + _lastAccessTime.TryRemove(chunk, out _); + semaphore.Dispose(); + } + + throw; + } + + return new LockReleaser(semaphore, chunk, this); + } + + /// + /// Tries to acquire a lock on a chunk asynchronously with a timeout. + /// + /// The chunk to lock + /// Timeout for acquiring the lock + /// Cancellation token + /// A tuple with a boolean indicating success and the lock releaser if successful + public async Task<(bool Success, IDisposable? LockReleaser)> TryLockAsync( + IChunk chunk, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + var semaphore = _locks.GetOrAdd(chunk, _ => new SemaphoreSlim(1, 1)); + _lastAccessTime[chunk] = DateTime.UtcNow; + + if (await semaphore.WaitAsync(timeout, cancellationToken).ConfigureAwait(false)) + { + return (true, new LockReleaser(semaphore, chunk, this)); + } + + return (false, null); + } + + /// + /// Cleans up unused locks. + /// + private void CleanupUnusedLocks(object? state) + { + var now = DateTime.UtcNow; + var threshold = now.AddMinutes(-_cleanupIntervalMinutes); + + foreach (var chunk in _lastAccessTime.Keys) + { + if (_lastAccessTime.TryGetValue(chunk, out var lastAccess) && + lastAccess < threshold && + _locks.TryGetValue(chunk, out var semaphore)) + { + // Only remove if no one is waiting + if (semaphore.CurrentCount == 1) + { + if (_locks.TryRemove(chunk, out var removedSemaphore)) + { + _lastAccessTime.TryRemove(chunk, out _); + removedSemaphore.Dispose(); + } + } + } + } + } + + /// + /// Disposes resources. + /// + public void Dispose() + { + _cleanupTimer.Dispose(); + + foreach (var semaphore in _locks.Values) + { + semaphore.Dispose(); + } + + _locks.Clear(); + _lastAccessTime.Clear(); + } + + /// + /// Releases a lock when disposed. + /// + private class LockReleaser : IDisposable + { + private readonly SemaphoreSlim _semaphore; + private readonly IChunk _chunk; + private readonly ChunkAsyncLock _parent; + private bool _disposed; + + public LockReleaser(SemaphoreSlim semaphore, IChunk chunk, ChunkAsyncLock parent) + { + _semaphore = semaphore; + _chunk = chunk; + _parent = parent; + } + + public void Dispose() + { + if (!_disposed) + { + _semaphore.Release(); + _parent._lastAccessTime[_chunk] = DateTime.UtcNow; + _disposed = true; + } + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkOperationQueue.cs b/advchksys/src/AdvChkSys/Threading/ChunkOperationQueue.cs new file mode 100644 index 0000000..601735c --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkOperationQueue.cs @@ -0,0 +1,274 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Threading +{ + /// + /// Provides a queue for sequential operations on chunks. + /// + public class ChunkOperationQueue + { + // Queue of pending operations for each chunk + private readonly ConcurrentDictionary Operation, TaskCompletionSource Completion)>> _pendingOperations = new(); + + // Currently active operations + private readonly ConcurrentDictionary _activeOperations = new(); + + // Semaphore to limit concurrent operations + private readonly SemaphoreSlim _semaphore; + + // Cancellation for shutdown + private readonly CancellationTokenSource _shutdownCts = new(); + + /// + /// Initializes a new instance of the ChunkOperationQueue class. + /// + /// Maximum number of concurrent operations + public ChunkOperationQueue(int maxConcurrentOperations = 4) + { + _semaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations); + } + + /// + /// Enqueues an operation to be performed on a chunk. + /// + /// The chunk to operate on + /// The operation to perform + /// A task that completes when the operation is done + public Task EnqueueOperationAsync(IChunk chunk, Func operation) + { + if (chunk == null) + throw new ArgumentNullException(nameof(chunk)); + + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + + // Create a completion source for this operation + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Get or create the queue for this chunk + var queue = _pendingOperations.GetOrAdd(chunk, _ => new Queue<(Func, TaskCompletionSource)>()); + + // Add the operation to the queue + lock (queue) + { + queue.Enqueue((operation, completion)); + + // If this is the only operation, start processing + if (queue.Count == 1 && !_activeOperations.ContainsKey(chunk)) + { + StartProcessingChunkOperations(chunk); + } + } + + return completion.Task; + } + + /// + /// Starts processing operations for a chunk. + /// + private async void StartProcessingChunkOperations(IChunk chunk) + { + // Wait for a semaphore slot + await _semaphore.WaitAsync(_shutdownCts.Token).ConfigureAwait(false); + + try + { + // Process operations until the queue is empty + while (!_shutdownCts.IsCancellationRequested) + { + // Get the next operation + (Func operation, TaskCompletionSource completion) nextOperation; + + var queue = _pendingOperations.GetOrAdd(chunk, _ => new Queue<(Func, TaskCompletionSource)>()); + + lock (queue) + { + if (queue.Count == 0) + { + // No more operations, remove the active task + _activeOperations.TryRemove(chunk, out _); + break; + } + + nextOperation = queue.Peek(); + } + + // Execute the operation + var task = ExecuteOperationAsync(chunk, nextOperation.operation, nextOperation.completion); + _activeOperations[chunk] = task; + + // Wait for completion + await task.ConfigureAwait(false); + + // Remove the completed operation + lock (queue) + { + queue.Dequeue(); + } + } + } + catch (OperationCanceledException) + { + // Shutdown requested + } + finally + { + // Release the semaphore + _semaphore.Release(); + } + } + + /// + /// Executes an operation and completes the task. + /// + private async Task ExecuteOperationAsync(IChunk chunk, Func operation, TaskCompletionSource completion) + { + try + { + // Track the operation for diagnostics + var operationId = ChunkThreadingDiagnostics.TrackOperationStart("ChunkOperation", chunk); + + try + { + // Execute the operation + await operation().ConfigureAwait(false); + + // Complete the task + completion.TrySetResult(null); + } + catch (Exception ex) + { + // Set the exception + completion.TrySetException(ex); + + // Log the error + ChunkThreadingDiagnostics.LogEvent("OperationError", $"Error in chunk operation: {ex.Message}"); + } + finally + { + // End tracking + ChunkThreadingDiagnostics.TrackOperationEnd(operationId); + } + } + catch (Exception ex) + { + // This should never happen, but just in case + completion.TrySetException(ex); + + // Log the error + ChunkThreadingDiagnostics.LogEvent("CriticalError", $"Critical error in operation execution: {ex.Message}"); + } + } + + /// + /// Gets the number of pending operations for a chunk. + /// + public int GetPendingOperationCount(IChunk chunk) + { + if (_pendingOperations.TryGetValue(chunk, out var queue)) + { + lock (queue) + { + return queue.Count; + } + } + + return 0; + } + + /// + /// Gets the total number of pending operations. + /// + public int PendingOperationCount + { + get + { + int count = 0; + foreach (var queue in _pendingOperations.Values) + { + lock (queue) + { + count += queue.Count; + } + } + return count; + } + } + + /// + /// Gets the number of active operations. + /// + public int ActiveOperationCount => _activeOperations.Count; + + /// + /// Cancels all pending operations for a chunk. + /// + /// The chunk to cancel operations for + /// The number of operations canceled + public int CancelOperations(IChunk chunk) + { + if (_pendingOperations.TryGetValue(chunk, out var queue)) + { + lock (queue) + { + int count = queue.Count; + + // Cancel all pending operations + while (queue.Count > 0) + { + var (Operation, Completion) = queue.Dequeue(); + Completion.TrySetCanceled(); + } + + return count; + } + } + + return 0; + } + + /// + /// Cancels all pending operations. + /// + /// The number of operations canceled + public int CancelAllOperations() + { + int count = 0; + + foreach (var chunk in _pendingOperations.Keys) + { + count += CancelOperations(chunk); + } + + return count; + } + + /// + /// Shuts down the operation queue. + /// + public async Task ShutdownAsync() + { + // Cancel all operations + _shutdownCts.Cancel(); + + // Cancel all pending operations + CancelAllOperations(); + + // Wait for active operations to complete + var tasks = new List(_activeOperations.Values); + if (tasks.Count > 0) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + // Dispose resources + _shutdownCts.Dispose(); + _semaphore.Dispose(); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkParallelProcessor.cs b/advchksys/src/AdvChkSys/Threading/ChunkParallelProcessor.cs new file mode 100644 index 0000000..05a4d37 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkParallelProcessor.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Threading +{ + /// + /// Provides advanced parallel processing for chunks. + /// + public static class ChunkParallelProcessor + { + /// + /// Processes chunks with dependency awareness. + /// + /// The chunks to process + /// The function to process each chunk + /// Function to get dependencies for a chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksWithDependenciesAsync( + IEnumerable chunks, + Func processor, + Func> getDependencies, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + // Build dependency graph + var dependencyGraph = BuildDependencyGraph(chunks, getDependencies); + + // Process in dependency order + await ProcessDependencyGraphAsync( + dependencyGraph, + processor, + maxDegreeOfParallelism, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a dependency graph for chunks. + /// + private static DependencyGraph BuildDependencyGraph( + IEnumerable chunks, + Func> getDependencies) + { + var graph = new DependencyGraph(); + + // Add all chunks to the graph + foreach (var chunk in chunks) + { + graph.AddNode(chunk); + } + + // Add dependencies + foreach (var chunk in chunks) + { + var dependencies = getDependencies(chunk); + foreach (var dependency in dependencies) + { + graph.AddDependency(chunk, dependency); + } + } + + return graph; + } + + /// + /// Processes a dependency graph in parallel. + /// + private static async Task ProcessDependencyGraphAsync( + DependencyGraph graph, + Func processor, + int? maxDegreeOfParallelism, + CancellationToken cancellationToken) + { + // Set up semaphore for parallelism control + var semaphore = new SemaphoreSlim( + maxDegreeOfParallelism ?? ChunkThreadingConfiguration.DefaultMaxDegreeOfParallelism); + + // Track completed chunks + var completed = new ConcurrentDictionary(); + + // Get initial set of chunks with no dependencies + var readyChunks = new ConcurrentQueue(graph.GetNodesWithNoDependencies()); + + // Track active tasks + var activeTasks = new ConcurrentDictionary(); + + // Process until all chunks are completed + while (!readyChunks.IsEmpty || activeTasks.Count > 0) + { + // Check for cancellation + cancellationToken.ThrowIfCancellationRequested(); + + // Start processing ready chunks + while (readyChunks.TryDequeue(out var chunk)) + { + // Wait for a semaphore slot + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Start processing this chunk + var task = ProcessChunkAsync( + chunk, + processor, + graph, + completed, + readyChunks, + semaphore, + cancellationToken); + + activeTasks[chunk] = task; + + // When the task completes, remove it from active tasks + _ = task.ContinueWith(_ => + { + activeTasks.TryRemove(chunk, out _); + }, TaskContinuationOptions.ExecuteSynchronously); + } + + // If no ready chunks but active tasks, wait for one to complete + if (readyChunks.IsEmpty && activeTasks.Count > 0) + { + await Task.WhenAny(activeTasks.Values).ConfigureAwait(false); + } + } + + // Clean up + semaphore.Dispose(); + } + + /// + /// Processes a single chunk and updates the ready queue. + /// + private static async Task ProcessChunkAsync( + IChunk chunk, + Func processor, + DependencyGraph graph, + ConcurrentDictionary completed, + ConcurrentQueue readyChunks, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + try + { + // Check for cancellation before processing + cancellationToken.ThrowIfCancellationRequested(); + + // Process the chunk + await processor(chunk).ConfigureAwait(false); + + // Check for cancellation after processing but before updating dependencies + cancellationToken.ThrowIfCancellationRequested(); + + // Mark as completed + completed[chunk] = true; + + // Find dependents that are now ready + var dependents = graph.GetDependents(chunk); + foreach (var dependent in dependents) + { + // Check for cancellation during dependency processing + cancellationToken.ThrowIfCancellationRequested(); + + // Check if all dependencies are completed + var dependencies = graph.GetDependencies(dependent); + if (dependencies.All(d => completed.ContainsKey(d))) + { + // All dependencies completed, add to ready queue + readyChunks.Enqueue(dependent); + } + } + } + finally + { + // Release the semaphore + semaphore.Release(); + } + } + + /// + /// Represents a dependency graph for chunks. + /// + private class DependencyGraph + { + // Map of chunk to its dependencies + private readonly Dictionary> _dependencies = new(); + + // Map of chunk to chunks that depend on it + private readonly Dictionary> _dependents = new(); + + /// + /// Adds a node to the graph. + /// + public void AddNode(IChunk chunk) + { + if (!_dependencies.ContainsKey(chunk)) + { + _dependencies[chunk] = new HashSet(); + } + + if (!_dependents.ContainsKey(chunk)) + { + _dependents[chunk] = new HashSet(); + } + } + + /// + /// Adds a dependency between two nodes. + /// + public void AddDependency(IChunk dependent, IChunk dependency) + { + // Add nodes if they don't exist + AddNode(dependent); + AddNode(dependency); + + // Add dependency + _dependencies[dependent].Add(dependency); + + // Add dependent + _dependents[dependency].Add(dependent); + } + + /// + /// Gets all nodes with no dependencies. + /// + public IEnumerable GetNodesWithNoDependencies() + { + return _dependencies.Where(kvp => kvp.Value.Count == 0).Select(kvp => kvp.Key); + } + + /// + /// Gets all dependencies of a node. + /// + public IEnumerable GetDependencies(IChunk chunk) + { + if (_dependencies.TryGetValue(chunk, out var dependencies)) + { + return dependencies; + } + + return Enumerable.Empty(); + } + + /// + /// Gets all dependents of a node. + /// + public IEnumerable GetDependents(IChunk chunk) + { + if (_dependents.TryGetValue(chunk, out var dependents)) + { + return dependents; + } + + return Enumerable.Empty(); + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkTaskScheduler.cs b/advchksys/src/AdvChkSys/Threading/ChunkTaskScheduler.cs new file mode 100644 index 0000000..e7933a9 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkTaskScheduler.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AdvChkSys.Threading +{ + /// + /// Provides utilities for running chunk-related tasks asynchronously. + /// Supports scheduling, cancellation, and custom task options. + /// + public static class ChunkTaskScheduler + { + // Default maximum degree of parallelism + private static int _maxDegreeOfParallelism = Environment.ProcessorCount; + + /// + /// Gets or sets the maximum degree of parallelism for batch operations. + /// + public static int MaxDegreeOfParallelism + { + get => _maxDegreeOfParallelism; + set => _maxDegreeOfParallelism = value > 0 ? value : Environment.ProcessorCount; + } + + /// + /// Runs the given action asynchronously on the thread pool. + /// + public static Task RunAsync(Action action, CancellationToken cancellationToken = default) + { + return Task.Run(action, cancellationToken); + } + + /// + /// Runs the given function asynchronously on the thread pool and returns a result. + /// + public static Task RunAsync(Func func, CancellationToken cancellationToken = default) + { + return Task.Run(func, cancellationToken); + } + + /// + /// Runs the given asynchronous function. + /// + public static Task RunAsync(Func func, CancellationToken cancellationToken = default) + { + return Task.Run(func, cancellationToken); + } + + /// + /// Runs the given asynchronous function and returns a result. + /// + public static Task RunAsync(Func> func, CancellationToken cancellationToken = default) + { + return Task.Run(func, cancellationToken); + } + + /// + /// Creates a cancellation token that cancels after a timeout. + /// + /// The timeout + /// Optional token to combine with the timeout + /// A cancellation token that cancels after the timeout or when the input token is canceled + public static CancellationToken CreateTimeoutToken( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (timeout == Timeout.InfiniteTimeSpan && cancellationToken == CancellationToken.None) + return CancellationToken.None; + + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + if (timeout != Timeout.InfiniteTimeSpan) + cts.CancelAfter(timeout); + + return cts.Token; + } + + /// + /// Runs a task with a timeout. + /// + /// The task to run + /// The timeout + /// Optional cancellation token + /// The task result + /// Thrown if the task times out + public static async Task WithTimeoutAsync( + Task task, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + var completedTask = await Task.WhenAny(task, Task.Delay(timeout, linkedCts.Token)) + .ConfigureAwait(false); + + if (completedTask == task) + { + return await task.ConfigureAwait(false); + } + + throw new TimeoutException($"The operation timed out after {timeout.TotalMilliseconds}ms"); + } + + /// + /// Runs a task with a timeout. + /// + /// The task to run + /// The timeout + /// Optional cancellation token + /// Thrown if the task times out + public static async Task WithTimeoutAsync( + Task task, + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + timeoutCts.Token, cancellationToken); + + var completedTask = await Task.WhenAny(task, Task.Delay(timeout, linkedCts.Token)) + .ConfigureAwait(false); + + if (completedTask == task) + { + await task.ConfigureAwait(false); + return; + } + + throw new TimeoutException($"The operation timed out after {timeout.TotalMilliseconds}ms"); + } + + /// + /// Runs a task with a fallback value if it times out. + /// + /// The type of result + /// The task to run + /// The timeout + /// The fallback value to return if the task times out + /// Optional cancellation token + /// The task result or the fallback value if the task times out + public static async Task WithTimeoutOrDefaultAsync( + Task task, + TimeSpan timeout, + T fallbackValue, + CancellationToken cancellationToken = default) + { + try + { + return await WithTimeoutAsync(task, timeout, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + return fallbackValue; + } + } + + /// + /// Runs multiple actions in parallel with a limit on the degree of parallelism. + /// + public static Task RunBatchParallelAsync(IEnumerable actions, int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + return Task.Run(() => + { + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? MaxDegreeOfParallelism, + CancellationToken = cancellationToken + }; + + Parallel.ForEach(actions, options, action => action()); + }, cancellationToken); + } + + /// + /// Runs a batch of functions in parallel and returns the results. + /// + /// The type of result + /// The functions to run + /// Maximum degree of parallelism (null for default) + /// Cancellation token + /// The results in the same order as the functions + public static async Task RunBatchParallelAsync( + IEnumerable> functions, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var funcs = functions.ToArray(); + var results = new T[funcs.Length]; + + await Task.Run(() => + { + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? MaxDegreeOfParallelism, + CancellationToken = cancellationToken + }; + + Parallel.For(0, funcs.Length, options, i => + { + results[i] = funcs[i](); + }); + }, cancellationToken).ConfigureAwait(false); + + return results; + } + + /// + /// Runs a batch of async functions in parallel. + /// + /// The async functions to run + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task RunBatchParallelAsync( + IEnumerable> functions, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var funcs = functions.ToArray(); + + if (funcs.Length == 0) + return; + + // Use SemaphoreSlim to limit concurrency + using var semaphore = new SemaphoreSlim( + maxDegreeOfParallelism ?? MaxDegreeOfParallelism); + + // Create tasks for all functions + var tasks = new List(funcs.Length); + + foreach (var func in funcs) + { + // Wait for a slot in the semaphore + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Create a task that releases the semaphore when done + var task = Task.Run(async () => + { + try + { + await func().ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + }, cancellationToken); + + tasks.Add(task); + } + + // Wait for all tasks to complete + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + /// + /// Runs a batch of async functions in parallel and returns the results. + /// + /// The type of result + /// The async functions to run + /// Maximum degree of parallelism (null for default) + /// Cancellation token + /// The results in the same order as the functions + public static async Task RunBatchParallelAsync( + IEnumerable>> functions, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var funcs = functions.ToArray(); + + if (funcs.Length == 0) + return Array.Empty(); + + // Use SemaphoreSlim to limit concurrency + using var semaphore = new SemaphoreSlim( + maxDegreeOfParallelism ?? MaxDegreeOfParallelism); + + // Create tasks for all functions + var tasks = new Task[funcs.Length]; + + for (int i = 0; i < funcs.Length; i++) + { + var func = funcs[i]; + var index = i; + + // Wait for a slot in the semaphore + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Create a task that releases the semaphore when done + tasks[index] = Task.Run(async () => + { + try + { + return await func().ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + }, cancellationToken); + } + + // Wait for all tasks to complete + return await Task.WhenAll(tasks).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkTaskSchedulerExtensions.cs b/advchksys/src/AdvChkSys/Threading/ChunkTaskSchedulerExtensions.cs new file mode 100644 index 0000000..e252071 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkTaskSchedulerExtensions.cs @@ -0,0 +1,63 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace AdvChkSys.Threading +{ + /// + /// Provides additional methods for the ChunkTaskScheduler. + /// + public static class ChunkTaskSchedulerExtensions + { + /// + /// Maximum degree of parallelism for chunk operations. + /// + public static int MaxDegreeOfParallelism { get; set; } = Environment.ProcessorCount; + + /// + /// Runs a batch of actions in parallel. + /// + /// The actions to run + /// Maximum degree of parallelism (null for default) + /// Cancellation token + /// A task that completes when all actions are done + public static Task RunBatchParallelAsync( + Action[] actions, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + if (actions == null || actions.Length == 0) + return Task.CompletedTask; + + return Task.Run(() => + { + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? MaxDegreeOfParallelism, + CancellationToken = cancellationToken + }; + + Parallel.ForEach(actions, options, action => action()); + }, cancellationToken); + } + + /// + /// Creates a cancellation token with a timeout. + /// + /// The timeout + /// Optional token to combine with the timeout + /// A cancellation token that will be canceled after the timeout or when the input token is canceled + public static CancellationToken CreateTimeoutToken( + TimeSpan timeout, + CancellationToken cancellationToken = default) + { + if (timeout == TimeSpan.MaxValue) + return cancellationToken; + + var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + source.CancelAfter(timeout); + return source.Token; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadSafetyManager.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadSafetyManager.cs new file mode 100644 index 0000000..4a8581b --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadSafetyManager.cs @@ -0,0 +1,237 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Threading +{ + /// + /// Provides synchronous locking for chunks. + /// + public class ChunkThreadSafetyManager : IDisposable + { + // Lock objects for each chunk + private readonly ConcurrentDictionary _locks = new(); + + // Timer for cleanup + private readonly Timer _cleanupTimer; + + // Last access time for each lock + private readonly ConcurrentDictionary _lastAccessTime = new(); + + // Cleanup interval in minutes + private readonly int _cleanupIntervalMinutes; + + /// + /// Initializes a new instance of the ChunkThreadSafetyManager class. + /// + /// Interval in minutes for cleaning up unused locks + public ChunkThreadSafetyManager(int cleanupIntervalMinutes = 10) + { + _cleanupIntervalMinutes = cleanupIntervalMinutes; + _cleanupTimer = new Timer(CleanupUnusedLocks, null, + TimeSpan.FromMinutes(cleanupIntervalMinutes), + TimeSpan.FromMinutes(cleanupIntervalMinutes)); + } + + /// + /// Acquires an exclusive lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireLock(IChunk chunk) + { + var lockObj = _locks.GetOrAdd(chunk, _ => new ChunkLock()); + _lastAccessTime[chunk] = DateTime.UtcNow; + + lockObj.EnterWriteLock(); + + return new LockReleaser(lockObj, chunk, this, LockType.Write); + } + + /// + /// Acquires a read lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireReadLock(IChunk chunk) + { + var lockObj = _locks.GetOrAdd(chunk, _ => new ChunkLock()); + _lastAccessTime[chunk] = DateTime.UtcNow; + + lockObj.EnterReadLock(); + + return new LockReleaser(lockObj, chunk, this, LockType.Read); + } + + /// + /// Acquires a write lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireWriteLock(IChunk chunk) + { + var lockObj = _locks.GetOrAdd(chunk, _ => new ChunkLock()); + _lastAccessTime[chunk] = DateTime.UtcNow; + + lockObj.EnterWriteLock(); + + return new LockReleaser(lockObj, chunk, this, LockType.Write); + } + + /// + /// Tries to acquire a read lock on a chunk. + /// + /// The chunk to lock + /// The timeout + /// A disposable that releases the lock when disposed, or null if the lock could not be acquired + public IDisposable? TryAcquireReadLock(IChunk chunk, TimeSpan timeout) + { + var lockObj = _locks.GetOrAdd(chunk, _ => new ChunkLock()); + _lastAccessTime[chunk] = DateTime.UtcNow; + + if (lockObj.TryEnterReadLock(timeout)) + { + return new LockReleaser(lockObj, chunk, this, LockType.Read); + } + + // Track contention + ChunkThreadingDiagnostics.TrackLockContention(chunk); + + return null; + } + + /// + /// Tries to acquire a write lock on a chunk. + /// + /// The chunk to lock + /// The timeout + /// A disposable that releases the lock when disposed, or null if the lock could not be acquired + public IDisposable? TryAcquireWriteLock(IChunk chunk, TimeSpan timeout) + { + var lockObj = _locks.GetOrAdd(chunk, _ => new ChunkLock()); + _lastAccessTime[chunk] = DateTime.UtcNow; + + if (lockObj.TryEnterWriteLock(timeout)) + { + return new LockReleaser(lockObj, chunk, this, LockType.Write); + } + + // Track contention + ChunkThreadingDiagnostics.TrackLockContention(chunk); + + return null; + } + + /// + /// Cleans up unused locks. + /// + private void CleanupUnusedLocks(object? state) + { + var now = DateTime.UtcNow; + var threshold = now.AddMinutes(-_cleanupIntervalMinutes); + + foreach (var chunk in _lastAccessTime.Keys) + { + if (_lastAccessTime.TryGetValue(chunk, out var lastAccess) && + lastAccess < threshold && + _locks.TryGetValue(chunk, out var lockObj)) + { + // Only remove if no one is using the lock + if (!lockObj.IsReadLockHeld && !lockObj.IsWriteLockHeld) + { + if (_locks.TryRemove(chunk, out var removedLock)) + { + _lastAccessTime.TryRemove(chunk, out _); + removedLock.Dispose(); + } + } + } + } + } + + /// + /// Disposes resources. + /// + public void Dispose() + { + _cleanupTimer.Dispose(); + + foreach (var lockObj in _locks.Values) + { + lockObj.Dispose(); + } + + _locks.Clear(); + _lastAccessTime.Clear(); + } + + /// + /// Type of lock. + /// + private enum LockType + { + Read, + Write + } + + /// + /// Wrapper around ReaderWriterLockSlim. + /// + private class ChunkLock : IDisposable + { + private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.SupportsRecursion); + + public void EnterReadLock() => _lock.EnterReadLock(); + public void ExitReadLock() => _lock.ExitReadLock(); + public void EnterWriteLock() => _lock.EnterWriteLock(); + public void ExitWriteLock() => _lock.ExitWriteLock(); + + public bool TryEnterReadLock(TimeSpan timeout) => _lock.TryEnterReadLock(timeout); + public bool TryEnterWriteLock(TimeSpan timeout) => _lock.TryEnterWriteLock(timeout); + + public bool IsReadLockHeld => _lock.IsReadLockHeld; + public bool IsWriteLockHeld => _lock.IsWriteLockHeld; + + public void Dispose() => _lock.Dispose(); + } + + /// + /// Releases a lock when disposed. + /// + private class LockReleaser : IDisposable + { + private readonly ChunkLock _lock; + private readonly IChunk _chunk; + private readonly ChunkThreadSafetyManager _parent; + private readonly LockType _lockType; + private bool _disposed; + + public LockReleaser(ChunkLock lockObj, IChunk chunk, ChunkThreadSafetyManager parent, LockType lockType) + { + _lock = lockObj; + _chunk = chunk; + _parent = parent; + _lockType = lockType; + } + + public void Dispose() + { + if (!_disposed) + { + if (_lockType == LockType.Read) + { + _lock.ExitReadLock(); + } + else + { + _lock.ExitWriteLock(); + } + + _parent._lastAccessTime[_chunk] = DateTime.UtcNow; + _disposed = true; + } + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingConfiguration.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingConfiguration.cs new file mode 100644 index 0000000..6dfd143 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingConfiguration.cs @@ -0,0 +1,98 @@ +using System; +using System.Threading; + +namespace AdvChkSys.Threading +{ + /// + /// Provides centralized configuration for threading in the chunk system. + /// + public static class ChunkThreadingConfiguration + { + private static int _defaultMaxDegreeOfParallelism = Environment.ProcessorCount; + private static int _chunkOperationTimeout = 30000; // 30 seconds + private static int _lockCleanupInterval = 10; // 10 minutes + + /// + /// Gets or sets the default maximum degree of parallelism for chunk operations. + /// + public static int DefaultMaxDegreeOfParallelism + { + get => _defaultMaxDegreeOfParallelism; + set + { + if (value < 1) + throw new ArgumentOutOfRangeException(nameof(value), "Parallelism must be at least 1"); + _defaultMaxDegreeOfParallelism = value; + ChunkTaskScheduler.MaxDegreeOfParallelism = value; + } + } + + /// + /// Gets or sets the timeout in milliseconds for chunk operations. + /// + public static int ChunkOperationTimeoutMs + { + get => _chunkOperationTimeout; + set + { + if (value < 0) + throw new ArgumentOutOfRangeException(nameof(value), "Timeout cannot be negative"); + _chunkOperationTimeout = value; + } + } + + /// + /// Gets or sets the interval in minutes for cleaning up unused locks. + /// + public static int LockCleanupIntervalMinutes + { + get => _lockCleanupInterval; + set + { + if (value < 1) + throw new ArgumentOutOfRangeException(nameof(value), "Cleanup interval must be at least 1 minute"); + _lockCleanupInterval = value; + } + } + + /// + /// Creates a cancellation token with the default timeout. + /// + public static CancellationToken CreateTimeoutToken(CancellationToken cancellationToken = default) + { + return ChunkTaskScheduler.CreateTimeoutToken( + TimeSpan.FromMilliseconds(_chunkOperationTimeout), + cancellationToken); + } + + /// + /// Configures the system for high throughput (more parallelism, longer timeouts). + /// + public static void ConfigureForHighThroughput() + { + DefaultMaxDegreeOfParallelism = Math.Max(4, Environment.ProcessorCount * 2); + ChunkOperationTimeoutMs = 60000; // 1 minute + LockCleanupIntervalMinutes = 30; + } + + /// + /// Configures the system for low latency (less parallelism, shorter timeouts). + /// + public static void ConfigureForLowLatency() + { + DefaultMaxDegreeOfParallelism = Math.Max(2, Environment.ProcessorCount / 2); + ChunkOperationTimeoutMs = 15000; // 15 seconds + LockCleanupIntervalMinutes = 5; + } + + /// + /// Configures the system for memory efficiency (less parallelism, more aggressive cleanup). + /// + public static void ConfigureForMemoryEfficiency() + { + DefaultMaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 4); + ChunkOperationTimeoutMs = 45000; // 45 seconds + LockCleanupIntervalMinutes = 2; + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingDiagnostics.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingDiagnostics.cs new file mode 100644 index 0000000..fed6988 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingDiagnostics.cs @@ -0,0 +1,273 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using AdvChkSys.Interfaces; + + +namespace AdvChkSys.Threading +{ + /// + /// Provides diagnostic information about threading operations in the chunk system. + /// + public static class ChunkThreadingDiagnostics + { + // Track operation durations + private static readonly ConcurrentDictionary> _operationDurations = new(); + + // Track lock contention + private static readonly ConcurrentDictionary _lockContentionCount = new(); + + // Track active operations + private static readonly ConcurrentDictionary _activeOperations = new(); + + // Lock for thread safety + private static readonly object _lock = new(); + + // Stopwatch for timing + private static readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + + /// + /// Tracks the duration of an operation. + /// + /// The name of the operation + /// The action to perform + public static void TrackOperation(string operationName, Action action) + { + _ = Guid.NewGuid(); + var startTime = _stopwatch.ElapsedMilliseconds; + + try + { + action(); + } + finally + { + var duration = _stopwatch.ElapsedMilliseconds - startTime; + + lock (_lock) + { + if (!_operationDurations.TryGetValue(operationName, out var durations)) + { + durations = new List(); + _operationDurations[operationName] = durations; + } + + durations.Add(duration); + + // Keep only the last 1000 durations + if (durations.Count > 1000) + { + durations.RemoveAt(0); + } + } + } + } + + /// + /// Tracks the start of an operation on a chunk. + /// + /// The name of the operation + /// The chunk being operated on + /// An operation ID for tracking + public static Guid TrackOperationStart(string operationName, IChunk chunk) + { + var operationId = Guid.NewGuid(); + _activeOperations[operationId] = (operationName, DateTime.UtcNow, chunk); + return operationId; + } + + /// + /// Tracks the end of an operation. + /// + /// The operation ID from TrackOperationStart + public static void TrackOperationEnd(Guid operationId) + { + if (_activeOperations.TryRemove(operationId, out var info)) + { + var duration = (DateTime.UtcNow - info.StartTime).TotalMilliseconds; + + lock (_lock) + { + if (!_operationDurations.TryGetValue(info.Operation, out var durations)) + { + durations = new List(); + _operationDurations[info.Operation] = durations; + } + + durations.Add((long)duration); + + // Keep only the last 1000 durations + if (durations.Count > 1000) + { + durations.RemoveAt(0); + } + } + } + } + + /// + /// Tracks lock contention on a chunk. + /// + /// The chunk that had lock contention + public static void TrackLockContention(IChunk chunk) + { + _lockContentionCount.AddOrUpdate(chunk, 1, (_, count) => count + 1); + } + + /// + /// Gets statistics about operation durations. + /// + public static Dictionary GetOperationStatistics() + { + var result = new Dictionary(); + + lock (_lock) + { + foreach (var kvp in _operationDurations) + { + var durations = kvp.Value; + if (durations.Count > 0) + { + result[kvp.Key] = ( + durations.Min(), + durations.Max(), + durations.Average(), + durations.Count + ); + } + } + } + + return result; + } + + /// + /// Gets the chunks with the most lock contention. + /// + /// Number of chunks to return + public static List<(IChunk Chunk, int ContentionCount)> GetTopContentionChunks(int topCount = 10) + { + return _lockContentionCount + .OrderByDescending(kvp => kvp.Value) + .Take(topCount) + .Select(kvp => (kvp.Key, kvp.Value)) + .ToList(); + } + + /// + /// Gets information about currently active operations. + /// + public static List<(string Operation, TimeSpan Duration, IChunk Chunk)> GetActiveOperations() + { + var now = DateTime.UtcNow; + return _activeOperations + .Select(kvp => ( + kvp.Value.Operation, + now - kvp.Value.StartTime, + kvp.Value.Chunk + )) + .OrderByDescending(x => x.Item2) + .ToList(); + } + + /// + /// Gets the total number of tracked operations. + /// + public static int GetTotalOperationCount() + { + lock (_lock) + { + return _operationDurations.Values.Sum(list => list.Count); + } + } + + /// + /// Gets the number of active operations. + /// + public static int GetActiveOperationCount() + { + return _activeOperations.Count; + } + + /// + /// Gets the total number of lock contentions. + /// + public static int GetTotalLockContentionCount() + { + return _lockContentionCount.Values.Sum(); + } + + /// + /// Clears all diagnostic data. + /// + public static void ClearDiagnosticData() + { + lock (_lock) + { + _operationDurations.Clear(); + _lockContentionCount.Clear(); + // Don't clear active operations as they're still in progress + } + } + + /// + /// Gets a comprehensive diagnostic report. + /// + public static string GenerateDiagnosticReport() + { + var report = new System.Text.StringBuilder(); + + report.AppendLine("=== Chunk Threading Diagnostic Report ==="); + report.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + report.AppendLine(); + + // Operation statistics + report.AppendLine("== Operation Statistics =="); + var stats = GetOperationStatistics(); + foreach (var kvp in stats.OrderByDescending(s => s.Value.Average)) + { + report.AppendLine($"{kvp.Key}:"); + report.AppendLine($" Count: {kvp.Value.Count}"); + report.AppendLine($" Min: {kvp.Value.Min}ms"); + report.AppendLine($" Max: {kvp.Value.Max}ms"); + report.AppendLine($" Avg: {kvp.Value.Average:F2}ms"); + } + report.AppendLine(); + + // Lock contention + report.AppendLine("== Lock Contention =="); + var contentions = GetTopContentionChunks(10); + foreach (var (chunk, count) in contentions) + { + report.AppendLine($"Chunk ({chunk.X}, {chunk.Y}): {count} contentions"); + } + report.AppendLine($"Total contentions: {GetTotalLockContentionCount()}"); + report.AppendLine(); + + // Active operations + report.AppendLine("== Active Operations =="); + var activeOps = GetActiveOperations(); + foreach (var (operation, duration, chunk) in activeOps) + { + report.AppendLine($"{operation} on Chunk ({chunk.X}, {chunk.Y}): {duration.TotalMilliseconds:F2}ms"); + } + report.AppendLine($"Total active operations: {GetActiveOperationCount()}"); + + return report.ToString(); + } + + /// + /// Logs a diagnostic event. + /// + /// The name of the event + /// Additional details + public static void LogEvent(string eventName, string details) + { + // This could be expanded to log to a file or other destination + Debug.WriteLine($"[{DateTime.UtcNow:HH:mm:ss.fff}] {eventName}: {details}"); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions.cs new file mode 100644 index 0000000..eeb5559 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions.cs @@ -0,0 +1,380 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; +using AdvChkSys.Manager; +using AdvChkSys.Loading; + +namespace AdvChkSys.Threading +{ + /// + /// Provides extension methods for threading operations on chunks. + /// + public static class ChunkThreadingExtensions + { + /// + /// Processes all loaded chunks in parallel. + /// + /// The type of chunk data + /// The chunk manager + /// The action to perform on each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static Task ProcessAllChunksParallelAsync( + this ChunkManager2D manager, + Action> action, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + return ChunkTaskScheduler.RunBatchParallelAsync( + chunks.Select(c => new Action(() => action(c))).ToArray(), + maxDegreeOfParallelism, + cancellationToken); + } + + /// + /// Processes all loaded chunks in parallel. + /// + /// The type of chunk data + /// The chunk manager + /// The action to perform on each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static Task ProcessAllChunksParallelAsync( + this ChunkManager3D manager, + Action> action, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + return ChunkTaskScheduler.RunBatchParallelAsync( + chunks.Select(c => new Action(() => action(c))).ToArray(), + maxDegreeOfParallelism, + cancellationToken); + } + + /// + /// Processes chunks in a region in parallel. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate + /// Minimum Y coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// The action to perform on each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksInRegionParallelAsync( + this ChunkManager2D manager, + int minX, int minY, int maxX, int maxY, + Action> action, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = new List>(); + + // Collect chunks in the region + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + var chunk = manager.GetChunk(x, y); + if (chunk != null) + { + chunks.Add(chunk); + } + } + } + + // Process in parallel + await ChunkTaskScheduler.RunBatchParallelAsync( + chunks.Select(c => new Action(() => action(c))).ToArray(), + maxDegreeOfParallelism, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Processes chunks in a region in parallel. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate + /// Minimum Y coordinate + /// Minimum Z coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// Maximum Z coordinate + /// The action to perform on each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksInRegionParallelAsync( + this ChunkManager3D manager, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + Action> action, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = new List>(); + + // Collect chunks in the region + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + for (int z = minZ; z <= maxZ; z++) + { + var chunk = manager.GetChunk(x, y, z); + if (chunk != null) + { + chunks.Add(chunk); + } + } + } + } + + // Process in parallel + await ChunkTaskScheduler.RunBatchParallelAsync( + chunks.Select(c => new Action(() => action(c))).ToArray(), + maxDegreeOfParallelism, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads chunks in a region asynchronously with a specified priority. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate + /// Minimum Y coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// Chunk width + /// Chunk height + /// Loading priority + /// Cancellation token + public static async Task LoadChunksInRegionAsync( + this ChunkManager2D manager, + int minX, int minY, int maxX, int maxY, + int width, int height, + ChunkLoadingPriority.Priority priority = ChunkLoadingPriority.Priority.Normal, + CancellationToken cancellationToken = default) + { + // Create a loading priority system if not already integrated + var loadingPriority = new ChunkLoadingPriority(); + + // Enqueue all chunks in the region + var tasks = new List(); + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + if (cancellationToken.IsCancellationRequested) + break; + + // Skip if already loaded + if (manager.IsChunkLoaded(x, y)) + continue; + + // Enqueue the request + var request = loadingPriority.EnqueueRequest(x, y, width, height, priority); + + // Create a task that completes when the chunk is loaded + var tcs = new TaskCompletionSource(); + + void Handler(object? sender, ChunkLoadingPriority.ChunkLoadRequest completedRequest) + { + if (completedRequest.RequestId == request.RequestId) + { + loadingPriority.RequestCompleted -= Handler; + tcs.TrySetResult(true); + } + } + + loadingPriority.RequestCompleted += Handler; + + // Add timeout to prevent indefinite waiting + _ = Task.Delay(30000, cancellationToken).ContinueWith(t => + { + loadingPriority.RequestCompleted -= Handler; + if (!t.IsCanceled) + tcs.TrySetResult(false); + }, cancellationToken); + + tasks.Add(tcs.Task); + } + } + + // Wait for all chunks to be loaded + await Task.WhenAll(tasks).ConfigureAwait(false); + + // Clean up + await loadingPriority.ShutdownAsync().ConfigureAwait(false); + } + + /// + /// Loads chunks in a region asynchronously with a specified priority. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate + /// Minimum Y coordinate + /// Minimum Z coordinate + /// Maximum X coordinate + /// Maximum Y coordinate + /// Maximum Z coordinate + /// Chunk width + /// Chunk height + /// Chunk depth + /// Loading priority + /// Cancellation token + public static async Task LoadChunksInRegionAsync( + this ChunkManager3D manager, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + int width, int height, int depth, + ChunkLoadingPriority.Priority priority = ChunkLoadingPriority.Priority.Normal, + CancellationToken cancellationToken = default) + { + // Create a loading priority system if not already integrated + var loadingPriority = new ChunkLoadingPriority(); + + // Enqueue all chunks in the region + var tasks = new List(); + for (int x = minX; x <= maxX; x++) + { + for (int y = minY; y <= maxY; y++) + { + for (int z = minZ; z <= maxZ; z++) + { + if (cancellationToken.IsCancellationRequested) + break; + + // Skip if already loaded + if (manager.IsChunkLoaded(x, y, z)) + continue; + + // Enqueue the request + var request = loadingPriority.EnqueueRequest(x, y, z, width, height, depth, priority); + + // Create a task that completes when the chunk is loaded + var tcs = new TaskCompletionSource(); + + void Handler(object? sender, ChunkLoadingPriority.ChunkLoadRequest completedRequest) + { + if (completedRequest.RequestId == request.RequestId) + { + loadingPriority.RequestCompleted -= Handler; + tcs.TrySetResult(true); + } + } + + loadingPriority.RequestCompleted += Handler; + + // Add timeout to prevent indefinite waiting + _ = Task.Delay(30000, cancellationToken).ContinueWith(t => + { + loadingPriority.RequestCompleted -= Handler; + if (!t.IsCanceled) + tcs.TrySetResult(false); + }, cancellationToken); + + tasks.Add(tcs.Task); + } + } + } + + // Wait for all chunks to be loaded + await Task.WhenAll(tasks).ConfigureAwait(false); + + // Clean up + await loadingPriority.ShutdownAsync().ConfigureAwait(false); + } + + /// + /// Unloads chunks outside a specified region asynchronously. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate to keep + /// Minimum Y coordinate to keep + /// Maximum X coordinate to keep + /// Maximum Y coordinate to keep + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task UnloadChunksOutsideRegionAsync( + this ChunkManager2D manager, + int minX, int minY, int maxX, int maxY, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + // Get all loaded chunks + var chunks = manager.GetAllChunks().ToArray(); + + // Filter chunks outside the region + var chunksToUnload = chunks.Where(c => + c.X < minX || c.X > maxX || c.Y < minY || c.Y > maxY).ToArray(); + + // Unload in parallel + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? Environment.ProcessorCount, + CancellationToken = cancellationToken + }; + + await Task.Run(() => + { + Parallel.ForEach(chunksToUnload, options, chunk => + { + manager.UnloadChunk(chunk.X, chunk.Y); + }); + }, cancellationToken).ConfigureAwait(false); + } + + /// + /// Unloads chunks outside a specified region asynchronously. + /// + /// The type of chunk data + /// The chunk manager + /// Minimum X coordinate to keep + /// Minimum Y coordinate to keep + /// Minimum Z coordinate to keep + /// Maximum X coordinate to keep + /// Maximum Y coordinate to keep + /// Maximum Z coordinate to keep + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task UnloadChunksOutsideRegionAsync( + this ChunkManager3D manager, + int minX, int minY, int minZ, int maxX, int maxY, int maxZ, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + // Get all loaded chunks + var chunks = manager.GetAllChunks().ToArray(); + + // Filter chunks outside the region + var chunksToUnload = chunks.Where(c => + c.X < minX || c.X > maxX || + c.Y < minY || c.Y > maxY || + c.Z < minZ || c.Z > maxZ).ToArray(); + + // Unload in parallel + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? Environment.ProcessorCount, + CancellationToken = cancellationToken + }; + + await Task.Run(() => + { + Parallel.ForEach(chunksToUnload, options, chunk => + { + manager.UnloadChunk(chunk.X, chunk.Y, chunk.Z); + }); + }, cancellationToken).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions2.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions2.cs new file mode 100644 index 0000000..2534982 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingExtensions2.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Chunk; +using AdvChkSys.Dependencies; +using AdvChkSys.Interfaces; +using AdvChkSys.Manager; + +namespace AdvChkSys.Threading +{ + /// + /// Provides additional extension methods for threading operations on chunks. + /// + public static class ChunkThreadingExtensions2 + { + /// + /// Processes chunks in a spiral pattern from the center outward. + /// + /// The type of chunk data + /// The chunk manager + /// Center X coordinate + /// Center Y coordinate + /// Radius in chunks + /// The function to process each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksSpiralAsync( + this ChunkManager2D manager, + int centerX, int centerY, int radius, + Func, Task> processor, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + // Generate spiral coordinates + var coordinates = GenerateSpiralCoordinates(centerX, centerY, radius).ToArray(); + + // Process in batches to maintain the spiral ordering while still using parallelism + int batchSize = Math.Max(1, (int)Math.Sqrt(coordinates.Length)); + int batchCount = (coordinates.Length + batchSize - 1) / batchSize; + + // Use the maxDegreeOfParallelism parameter to limit concurrent tasks + int parallelism = maxDegreeOfParallelism ?? ChunkThreadingConfiguration.DefaultMaxDegreeOfParallelism; + var semaphore = new SemaphoreSlim(parallelism, parallelism); + + for (int i = 0; i < batchCount; i++) + { + if (cancellationToken.IsCancellationRequested) + break; + + var batchCoords = coordinates + .Skip(i * batchSize) + .Take(batchSize) + .ToArray(); + + var tasks = new List(); + foreach (var (x, y) in batchCoords) + { + var chunk = manager.GetChunk(x, y); + if (chunk != null) + { + // Wait for a slot in the semaphore before starting a new task + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + + // Create a task that releases the semaphore when done + var task = Task.Run(async () => + { + try + { + await processor(chunk).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + }, cancellationToken); + + tasks.Add(task); + } + } + + if (tasks.Count > 0) + { + await Task.WhenAll(tasks).ConfigureAwait(false); + } + } + + // Clean up the semaphore + semaphore.Dispose(); + } + + /// + /// Generates coordinates in a spiral pattern from the center outward. + /// + private static IEnumerable<(int X, int Y)> GenerateSpiralCoordinates(int centerX, int centerY, int radius) + { + // Start with the center + yield return (centerX, centerY); + + // Spiral outward + for (int layer = 1; layer <= radius; layer++) + { + // Top edge (moving right) + for (int x = centerX - layer + 1; x <= centerX + layer; x++) + { + yield return (x, centerY - layer); + } + + // Right edge (moving down) + for (int y = centerY - layer + 1; y <= centerY + layer; y++) + { + yield return (centerX + layer, y); + } + + // Bottom edge (moving left) + for (int x = centerX + layer - 1; x >= centerX - layer; x--) + { + yield return (x, centerY + layer); + } + + // Left edge (moving up) + for (int y = centerY + layer - 1; y >= centerY - layer; y--) + { + yield return (centerX - layer, y); + } + } + } + + /// + /// Processes chunks with dependency awareness. + /// + /// The type of chunk data + /// The chunk manager + /// The dependency tracker + /// The function to process each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksWithDependenciesAsync( + this ChunkManager2D manager, + ChunkDependencyTracker dependencyTracker, + Func, Task> processor, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + + await ChunkParallelProcessor.ProcessChunksWithDependenciesAsync( + chunks, + chunk => processor((Chunk2D)chunk), + chunk => dependencyTracker.GetDependencies(chunk), + maxDegreeOfParallelism, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Processes chunks with dependency awareness. + /// + /// The type of chunk data + /// The chunk manager + /// The dependency tracker + /// The function to process each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksWithDependenciesAsync( + this ChunkManager3D manager, + ChunkDependencyTracker dependencyTracker, + Func, Task> processor, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + + await ChunkParallelProcessor.ProcessChunksWithDependenciesAsync( + chunks, + chunk => processor((Chunk3D)chunk), + chunk => dependencyTracker.GetDependencies(chunk), + maxDegreeOfParallelism, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Processes chunks with thread safety. + /// + /// The type of chunk data + /// The chunk manager + /// The function to process each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksSafelyAsync( + this ChunkManager2D manager, + Func, Task> processor, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + var threadingManager = ChunkThreadingManager.Instance; + + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? ChunkThreadingConfiguration.DefaultMaxDegreeOfParallelism, + CancellationToken = cancellationToken + }; + + await Task.Run(() => Parallel.ForEach(chunks, options, async chunk => + { + await threadingManager.WithLockAsync(chunk, async () => + { + await processor(chunk).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + }), cancellationToken).ConfigureAwait(false); + } + + /// + /// Processes chunks with thread safety. + /// + /// The type of chunk data + /// The chunk manager + /// The function to process each chunk + /// Maximum degree of parallelism (null for default) + /// Cancellation token + public static async Task ProcessChunksSafelyAsync( + this ChunkManager3D manager, + Func, Task> processor, + int? maxDegreeOfParallelism = null, + CancellationToken cancellationToken = default) + { + var chunks = manager.GetAllChunks().ToArray(); + var threadingManager = ChunkThreadingManager.Instance; + + var options = new ParallelOptions + { + MaxDegreeOfParallelism = maxDegreeOfParallelism ?? ChunkThreadingConfiguration.DefaultMaxDegreeOfParallelism, + CancellationToken = cancellationToken + }; + + await Task.Run(() => Parallel.ForEach(chunks, options, async chunk => + { + await threadingManager.WithLockAsync(chunk, async () => + { + await processor(chunk).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + }), cancellationToken).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingManager.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingManager.cs new file mode 100644 index 0000000..b424c9d --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingManager.cs @@ -0,0 +1,280 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AdvChkSys.Interfaces; + +namespace AdvChkSys.Threading +{ + /// + /// Provides a centralized manager for all threading operations in the chunk system. + /// + public class ChunkThreadingManager : IDisposable + { + private static ChunkThreadingManager? _instance; + private static readonly object _instanceLock = new(); + + // Thread safety utilities + private readonly ChunkThreadSafetyManager _threadSafety; + private readonly ChunkAsyncLock _asyncLock; + + // Operation queue + private readonly ChunkOperationQueue _operationQueue; + + // Cancellation for shutdown + private readonly CancellationTokenSource _shutdownCts = new(); + + /// + /// Gets the singleton instance of the ChunkThreadingManager. + /// + public static ChunkThreadingManager Instance + { + get + { + if (_instance == null) + { + lock (_instanceLock) + { + _instance ??= new ChunkThreadingManager(); + } + } + return _instance; + } + } + + /// + /// Initializes a new instance of the ChunkThreadingManager class. + /// + private ChunkThreadingManager() + { + _threadSafety = new ChunkThreadSafetyManager( + ChunkThreadingConfiguration.LockCleanupIntervalMinutes); + + _asyncLock = new ChunkAsyncLock( + ChunkThreadingConfiguration.LockCleanupIntervalMinutes); + + _operationQueue = new ChunkOperationQueue( + ChunkThreadingConfiguration.DefaultMaxDegreeOfParallelism); + } + + /// + /// Gets the thread safety manager for synchronous locking. + /// + public ChunkThreadSafetyManager ThreadSafety => _threadSafety; + + /// + /// Gets the async lock manager for asynchronous locking. + /// + public ChunkAsyncLock AsyncLock => _asyncLock; + + /// + /// Gets the operation queue for sequential chunk operations. + /// + public ChunkOperationQueue OperationQueue => _operationQueue; + + /// + /// Acquires an exclusive lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireLock(IChunk chunk) + { + return _threadSafety.AcquireLock(chunk); + } + + /// + /// Acquires a read lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireReadLock(IChunk chunk) + { + return _threadSafety.AcquireReadLock(chunk); + } + + /// + /// Acquires a write lock on a chunk. + /// + /// The chunk to lock + /// A disposable that releases the lock when disposed + public IDisposable AcquireWriteLock(IChunk chunk) + { + return _threadSafety.AcquireWriteLock(chunk); + } + + /// + /// Acquires a lock on a chunk asynchronously. + /// + /// The chunk to lock + /// Cancellation token + /// A disposable that releases the lock when disposed + public Task LockAsync(IChunk chunk, CancellationToken cancellationToken = default) + { + return _asyncLock.LockAsync(chunk, cancellationToken); + } + + /// + /// Enqueues an operation to be performed on a chunk. + /// + /// The chunk to operate on + /// The operation to perform + /// A task that completes when the operation is done + public Task EnqueueOperationAsync(IChunk chunk, Func operation) + { + return _operationQueue.EnqueueOperationAsync(chunk, operation); + } + + /// + /// Runs an action with a lock on the chunk. + /// + /// The chunk to lock + /// The action to perform + public void WithLock(IChunk chunk, Action action) + { + using var lockObj = AcquireLock(chunk); + action(); + } + + /// + /// Runs a function with a lock on the chunk and returns the result. + /// + /// The type of result + /// The chunk to lock + /// The function to perform + /// The result of the function + public T WithLock(IChunk chunk, Func func) + { + using var lockObj = AcquireLock(chunk); + return func(); + } + + /// + /// Runs an async action with a lock on the chunk. + /// + /// The chunk to lock + /// The async action to perform + /// Cancellation token + public async Task WithLockAsync(IChunk chunk, Func action, CancellationToken cancellationToken = default) + { + using var lockObj = await LockAsync(chunk, cancellationToken).ConfigureAwait(false); + await action().ConfigureAwait(false); + } + + /// + /// Runs an async function with a lock on the chunk and returns the result. + /// + /// The type of result + /// The chunk to lock + /// The async function to perform + /// Cancellation token + /// The result of the function + public async Task WithLockAsync(IChunk chunk, Func> func, CancellationToken cancellationToken = default) + { + using var lockObj = await LockAsync(chunk, cancellationToken).ConfigureAwait(false); + return await func().ConfigureAwait(false); + } + + /// + /// Runs an action on multiple chunks with proper locking to avoid deadlocks. + /// + /// The chunks to lock, in order + /// The action to perform + public void WithMultiLock(IChunk[] chunks, Action action) + { + // Sort chunks by ID to prevent deadlocks + var sortedChunks = SortChunksById(chunks); + + // Acquire locks in order + var locks = new IDisposable[sortedChunks.Length]; + try + { + for (int i = 0; i < sortedChunks.Length; i++) + { + locks[i] = AcquireLock(sortedChunks[i]); + } + + // Execute the action + action(); + } + finally + { + // Release locks in reverse order + for (int i = locks.Length - 1; i >= 0; i--) + { + locks[i]?.Dispose(); + } + } + } + + /// + /// Runs an async action on multiple chunks with proper locking to avoid deadlocks. + /// + /// The chunks to lock, in order + /// The async action to perform + /// Cancellation token + public async Task WithMultiLockAsync(IChunk[] chunks, Func action, CancellationToken cancellationToken = default) + { + // Sort chunks by ID to prevent deadlocks + var sortedChunks = SortChunksById(chunks); + + // Acquire locks in order + var locks = new IDisposable[sortedChunks.Length]; + try + { + for (int i = 0; i < sortedChunks.Length; i++) + { + locks[i] = await LockAsync(sortedChunks[i], cancellationToken).ConfigureAwait(false); + } + + // Execute the action + await action().ConfigureAwait(false); + } + finally + { + // Release locks in reverse order + for (int i = locks.Length - 1; i >= 0; i--) + { + locks[i]?.Dispose(); + } + } + } + + /// + /// Sorts chunks by ID to prevent deadlocks when acquiring multiple locks. + /// + private IChunk[] SortChunksById(IChunk[] chunks) + { + // Use System.Linq for OrderBy + return chunks.OrderBy(c => c.GetHashCode()).ToArray(); + } + + /// + /// Disposes all resources. + /// + public void Dispose() + { + _shutdownCts.Cancel(); + + // Shutdown operation queue + _operationQueue.ShutdownAsync().GetAwaiter().GetResult(); + + // Dispose thread safety managers + (_threadSafety as IDisposable)?.Dispose(); + (_asyncLock as IDisposable)?.Dispose(); + + _shutdownCts.Dispose(); + } + + /// + /// Resets the singleton instance (for testing). + /// + internal static void ResetInstance() + { + lock (_instanceLock) + { + _instance?.Dispose(); + _instance = null; + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/ChunkThreadingPerformanceMonitor.cs b/advchksys/src/AdvChkSys/Threading/ChunkThreadingPerformanceMonitor.cs new file mode 100644 index 0000000..cc72bb0 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/ChunkThreadingPerformanceMonitor.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; + + +namespace AdvChkSys.Threading +{ + /// + /// Monitors and reports on threading performance in the chunk system. + /// + public class ChunkThreadingPerformanceMonitor : IDisposable + { + // Performance metrics + private readonly ConcurrentDictionary _metrics = new(); + + // Sampling timer + private readonly Timer _samplingTimer; + + // Sampling interval + private readonly TimeSpan _samplingInterval; + + // Maximum history to keep + private readonly int _maxHistory; + + // Whether monitoring is enabled + private bool _isEnabled; + + /// + /// Initializes a new instance of the ChunkThreadingPerformanceMonitor class. + /// + /// Sampling interval in milliseconds + /// Maximum number of samples to keep + public ChunkThreadingPerformanceMonitor(int samplingIntervalMs = 1000, int maxHistory = 60) + { + _samplingInterval = TimeSpan.FromMilliseconds(samplingIntervalMs); + _maxHistory = maxHistory; + _samplingTimer = new Timer(SamplePerformance, null, Timeout.Infinite, Timeout.Infinite); + } + + /// + /// Starts monitoring performance. + /// + public void Start() + { + if (!_isEnabled) + { + _isEnabled = true; + _samplingTimer.Change(TimeSpan.Zero, _samplingInterval); + } + } + + /// + /// Stops monitoring performance. + /// + public void Stop() + { + if (_isEnabled) + { + _isEnabled = false; + _samplingTimer.Change(Timeout.Infinite, Timeout.Infinite); + } + } + + /// + /// Samples performance metrics. + /// + private void SamplePerformance(object? state) + { + if (!_isEnabled) + return; + + // Sample thread pool information + ThreadPool.GetAvailableThreads(out int workerThreads, out int completionPortThreads); + ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads); + + // Calculate thread pool usage + double workerThreadUsage = 1.0 - ((double)workerThreads / maxWorkerThreads); + double ioThreadUsage = 1.0 - ((double)completionPortThreads / maxCompletionPortThreads); + + // Update metrics + UpdateMetric("ThreadPool.WorkerThreadUsage", workerThreadUsage); + UpdateMetric("ThreadPool.IOThreadUsage", ioThreadUsage); + + // Sample operation queue information + var operationQueue = ChunkThreadingManager.Instance.OperationQueue; + UpdateMetric("OperationQueue.PendingOperations", operationQueue.PendingOperationCount); + UpdateMetric("OperationQueue.ActiveOperations", operationQueue.ActiveOperationCount); + + // Sample diagnostics information + UpdateMetric("Diagnostics.ActiveOperations", ChunkThreadingDiagnostics.GetActiveOperationCount()); + UpdateMetric("Diagnostics.LockContentions", ChunkThreadingDiagnostics.GetTotalLockContentionCount()); + + // Sample process information + var process = Process.GetCurrentProcess(); + // Fix: Use process.TotalProcessorTime and calculate uptime manually + TimeSpan upTime = DateTime.Now - process.StartTime; + UpdateMetric("Process.CPU", process.TotalProcessorTime.TotalMilliseconds / Environment.ProcessorCount / upTime.TotalMilliseconds); + UpdateMetric("Process.Memory", process.WorkingSet64 / 1024.0 / 1024.0); // MB + UpdateMetric("Process.Threads", process.Threads.Count); + } + + /// + /// Updates a performance metric. + /// + private void UpdateMetric(string name, double value) + { + if (!_metrics.TryGetValue(name, out var metric)) + { + metric = new PerformanceMetric(_maxHistory); + _metrics[name] = metric; + } + + metric.AddSample(value); + } + + /// + /// Gets a performance report. + /// + public Dictionary GetPerformanceReport() + { + var report = new Dictionary(); + + foreach (var kvp in _metrics) + { + report[kvp.Key] = kvp.Value.GetReport(); + } + + return report; + } + + /// + /// Gets a formatted performance report. + /// + public string GetFormattedReport() + { + var report = new System.Text.StringBuilder(); + + report.AppendLine("=== Chunk Threading Performance Report ==="); + report.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + report.AppendLine(); + + var metrics = _metrics.OrderBy(m => m.Key).ToList(); + foreach (var kvp in metrics) + { + var metricReport = kvp.Value.GetReport(); + report.AppendLine($"{kvp.Key}:"); + report.AppendLine($" Current: {metricReport.Current:F3}"); + report.AppendLine($" Average: {metricReport.Average:F3}"); + report.AppendLine($" Min: {metricReport.Min:F3}"); + report.AppendLine($" Max: {metricReport.Max:F3}"); + report.AppendLine(); + } + + return report.ToString(); + } + + /// + /// Disposes resources. + /// + public void Dispose() + { + Stop(); + _samplingTimer.Dispose(); + } + + /// + /// Represents a performance metric with history. + /// + private class PerformanceMetric + { + private readonly Queue _samples; + private readonly int _maxSamples; + + public PerformanceMetric(int maxSamples) + { + _maxSamples = maxSamples; + _samples = new Queue(maxSamples); + } + + public void AddSample(double value) + { + lock (_samples) + { + _samples.Enqueue(value); + + while (_samples.Count > _maxSamples) + { + _samples.Dequeue(); + } + } + } + + public PerformanceReport GetReport() + { + lock (_samples) + { + if (_samples.Count == 0) + { + return new PerformanceReport(0, 0, 0, 0, Array.Empty()); + } + + var samplesArray = _samples.ToArray(); + return new PerformanceReport( + samplesArray.Last(), + samplesArray.Average(), + samplesArray.Min(), + samplesArray.Max(), + samplesArray + ); + } + } + } + + /// + /// Represents a performance report. + /// + public class PerformanceReport + { + /// + /// The current value. + /// + public double Current { get; } + + /// + /// The average value. + /// + public double Average { get; } + + /// + /// The minimum value. + /// + public double Min { get; } + + /// + /// The maximum value. + /// + public double Max { get; } + + /// + /// The history of values. + /// + public double[] History { get; } + + /// + /// + /// + /// + /// + /// + /// + /// + public PerformanceReport(double current, double average, double min, double max, double[] history) + { + Current = current; + Average = average; + Min = min; + Max = max; + History = history; + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Threading/LimitedConcurrencyTaskScheduler.cs b/advchksys/src/AdvChkSys/Threading/LimitedConcurrencyTaskScheduler.cs new file mode 100644 index 0000000..d1e2316 --- /dev/null +++ b/advchksys/src/AdvChkSys/Threading/LimitedConcurrencyTaskScheduler.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace AdvChkSys.Threading +{ + /// + /// Provides a task scheduler that ensures a maximum concurrency level while + /// running on top of the ThreadPool. + /// + internal class LimitedConcurrencyTaskScheduler : TaskScheduler + { + // Indicates whether the current thread is processing work items. + [ThreadStatic] + private static bool _currentThreadIsProcessingItems; + + // The list of tasks to be executed + private readonly LinkedList _tasks = new(); + + // The maximum concurrency level allowed by this scheduler. + private int _maximumConcurrencyLevel; + + // Indicates whether the scheduler is currently processing work items. + private int _delegatesQueuedOrRunning; + + /// + /// Creates a new instance with the specified degree of parallelism. + /// + /// The maximum degree of parallelism + public LimitedConcurrencyTaskScheduler(int maximumConcurrencyLevel) + { + if (maximumConcurrencyLevel < 1) + throw new ArgumentOutOfRangeException(nameof(maximumConcurrencyLevel)); + _maximumConcurrencyLevel = maximumConcurrencyLevel; + } + + /// + /// Gets the maximum concurrency level supported by this scheduler. + /// + public override int MaximumConcurrencyLevel => _maximumConcurrencyLevel; + + /// + /// Sets the maximum concurrency level for this scheduler. + /// + /// The new maximum concurrency level + public void SetMaximumConcurrencyLevel(int value) + { + if (value < 1) + throw new ArgumentOutOfRangeException(nameof(value)); + _maximumConcurrencyLevel = value; + } + + /// + /// Queues a task to the scheduler. + /// + protected sealed override void QueueTask(Task task) + { + // Add the task to the list of tasks to be processed. + lock (_tasks) + { + _tasks.AddLast(task); + } + + // If there aren't enough delegates currently queued or running to process + // tasks, schedule another. + if (Interlocked.Increment(ref _delegatesQueuedOrRunning) <= _maximumConcurrencyLevel) + { + ThreadPool.QueueUserWorkItem(ProcessQueuedTasks); + } + else + { + Interlocked.Decrement(ref _delegatesQueuedOrRunning); + } + } + + /// + /// Attempts to execute the specified task on the current thread. + /// + protected sealed override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) + { + // If this thread isn't already processing a task, we don't support inlining + if (!_currentThreadIsProcessingItems) + return false; + + // If the task was previously queued, remove it from the queue + if (taskWasPreviouslyQueued) + { + // Try to remove the task from the queue. + lock (_tasks) + { + if (_tasks.Contains(task)) + { + _tasks.Remove(task); + } + else + { + // The task isn't in the queue anymore, so it was probably already executed + return false; + } + } + } + + // Try to execute the task. + bool result = TryExecuteTask(task); + return result; + } + + /// + /// Attempts to remove a previously scheduled task from the scheduler. + /// + protected sealed override bool TryDequeue(Task task) + { + lock (_tasks) + { + return _tasks.Remove(task); + } + } + + /// + /// Gets an enumerable of the tasks currently scheduled on this scheduler. + /// + protected sealed override IEnumerable GetScheduledTasks() + { + lock (_tasks) + { + return new List(_tasks); + } + } + + /// + /// Processes tasks in the queue. + /// + private void ProcessQueuedTasks(object? state) + { + // This thread is now processing work items. + _currentThreadIsProcessingItems = true; + try + { + // Process all available items in the queue. + while (true) + { + Task? task = null; + lock (_tasks) + { + // When there are no more items to be processed, + // note that we're done processing, and get out. + if (_tasks.Count == 0) + { + Interlocked.Decrement(ref _delegatesQueuedOrRunning); + break; + } + + // Get the next item from the queue + task = _tasks.First!.Value; + _tasks.RemoveFirst(); + } + + // Execute the task we pulled out of the queue + TryExecuteTask(task); + } + } + finally + { + // We're done processing items on the current thread + _currentThreadIsProcessingItems = false; + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Util/CacheCapacityHelper.cs b/advchksys/src/AdvChkSys/Util/CacheCapacityHelper.cs new file mode 100644 index 0000000..fbce962 --- /dev/null +++ b/advchksys/src/AdvChkSys/Util/CacheCapacityHelper.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.InteropServices; + +namespace AdvChkSys.Util +{ + /// + /// Provides utility methods for calculating optimal cache capacities based on system memory. + /// + public static class CacheCapacityHelper + { + /// + /// Calculates the maximum number of chunks that can fit in available memory. + /// + /// Chunk width (cells) + /// Chunk height (cells) + /// Size of a single element in bytes (e.g., use sizeof(byte) or Marshal.SizeOf(typeof(T))) + /// Fraction of available memory to use (0.8 = 80%) + public static int CalculateChunkCapacity(int chunkWidth, int chunkHeight, int elementSize, double safetyFactor = 0.8) + { + ulong availableBytes = MemoryHelper.GetAvailableMemoryBytes(); + ulong usableBytes = (ulong)(availableBytes * safetyFactor); + + int bytesPerChunk = chunkWidth * chunkHeight * elementSize; + if (bytesPerChunk == 0) bytesPerChunk = 1; // avoid div by zero + + ulong maxChunks = usableBytes / (ulong)bytesPerChunk; + return (int)Math.Max(1, Math.Min(maxChunks, int.MaxValue)); + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Util/LRUCache.cs b/advchksys/src/AdvChkSys/Util/LRUCache.cs new file mode 100644 index 0000000..b019383 --- /dev/null +++ b/advchksys/src/AdvChkSys/Util/LRUCache.cs @@ -0,0 +1,135 @@ +#nullable enable +using System; +using System.Collections.Generic; + +namespace AdvChkSys.Util +{ + /// + /// Thread-safe LRU (Least Recently Used) cache. + /// + public class LRUCache + where TKey : notnull + { + private readonly int _capacity; + private readonly Dictionary> _map; + private readonly LinkedList<(TKey key, TValue value)> _lruList; + private readonly object _lock = new(); + + /// + /// Initializes a new instance of the LRU cache with the specified capacity. + /// + /// Maximum number of items to store in the cache + public LRUCache(int capacity) + { + if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); + _capacity = capacity; + _map = new Dictionary>(); + _lruList = new LinkedList<(TKey, TValue)>(); + } + + /// + /// Attempts to retrieve a value from the cache by key. + /// + /// The key to look up + /// The retrieved value if found, default otherwise + /// True if the key was found, false otherwise + public bool TryGet(TKey key, out TValue value) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var node)) + { + _lruList.Remove(node); + _lruList.AddFirst(node); + value = node.Value.value; + return true; + } + value = default!; + return false; + } + } + + /// + /// Adds or updates a key-value pair in the cache. + /// + /// The key to add or update + /// The value to store + /// Optional callback when an item is evicted + public void Add(TKey key, TValue value, Action? onEvict = null) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var node)) + { + _lruList.Remove(node); + _lruList.AddFirst(node); + node.Value = (key, value); + } + else + { + if (_map.Count >= _capacity) + { + var last = _lruList.Last; + if (last != null) + { + _map.Remove(last.Value.key); + onEvict?.Invoke(last.Value.key, last.Value.value); + _lruList.RemoveLast(); + } + } + var newNode = new LinkedListNode<(TKey, TValue)>((key, value)); + _lruList.AddFirst(newNode); + _map[key] = newNode; + } + } + } + + /// + /// Removes a key-value pair from the cache. + /// + /// The key to remove + /// The removed value if found + /// True if the key was found and removed, false otherwise + public bool Remove(TKey key, out TValue? value) + { + lock (_lock) + { + if (_map.TryGetValue(key, out var node)) + { + value = node.Value.value; + _lruList.Remove(node); + _map.Remove(key); + return true; + } + value = default; + return false; + } + } + + /// + /// Gets all values currently in the cache. + /// + public IEnumerable Values + { + get + { + lock (_lock) + { + foreach (var (_, value) in _lruList) + yield return value; + } + } + } + + /// + /// Gets the current number of items in the cache. + /// + public int Count + { + get + { + lock (_lock) { return _map.Count; } + } + } + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/Util/MemoryHelper.cs b/advchksys/src/AdvChkSys/Util/MemoryHelper.cs new file mode 100644 index 0000000..e7ec776 --- /dev/null +++ b/advchksys/src/AdvChkSys/Util/MemoryHelper.cs @@ -0,0 +1,143 @@ +using System; +using System.Runtime.InteropServices; + +namespace AdvChkSys.Util +{ + /// + /// Provides utility methods for querying system memory information. + /// + public static class MemoryHelper + { + /// + /// Gets the amount of available physical memory in bytes. + /// + /// Available memory in bytes + public static ulong GetAvailableMemoryBytes() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return GetAvailableMemoryWindows(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return GetAvailableMemoryLinux(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return GetAvailableMemoryMac(); + } + else + { + // Fallback: unknown, assume 1GB + return 1UL << 30; + } + } + + private static ulong GetAvailableMemoryWindows() + { + var memStatus = new MEMORYSTATUSEX + { + dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)) + }; + if (GlobalMemoryStatusEx(ref memStatus)) + { + return memStatus.ullAvailPhys; + } + return 1UL << 30; + } + + private static ulong GetAvailableMemoryLinux() + { + try + { + string[] lines = System.IO.File.ReadAllLines("/proc/meminfo"); + ulong memFree = 0, buffers = 0, cached = 0; + foreach (var line in lines) + { + if (line.StartsWith("MemAvailable:")) + memFree = ParseKb(line); + else if (line.StartsWith("Buffers:")) + buffers = ParseKb(line); + else if (line.StartsWith("Cached:")) + cached = ParseKb(line); + } + return memFree > 0 ? memFree * 1024 : (buffers + cached) * 1024; + } + catch + { + return 1UL << 30; + } + } + + private static ulong GetAvailableMemoryMac() + { + // macOS: fallback to 1GB (implementing this is complex and rarely needed) + return 1UL << 30; + } + + private static ulong ParseKb(string line) + { + var parts = line.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2 && ulong.TryParse(parts[1], out var kb)) + return kb; + return 0; + } + + /// + /// Structure containing memory status information for Windows systems. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public struct MEMORYSTATUSEX + { + /// + /// The size of the structure, in bytes. You must set this member before calling GlobalMemoryStatusEx. + /// + public uint dwLength; + /// + /// A number between 0 and 100 that specifies the approximate percentage of physical memory + /// that is in use (0 indicates no memory use and 100 indicates full memory use). + /// + public uint dwMemoryLoad; + /// + /// The total size of physical memory, in bytes. + /// + public ulong ullTotalPhys; + /// + /// The amount of physical memory currently available, in bytes. + /// This is the amount of physical memory that can be immediately reused without having to write its contents to disk first. + /// + public ulong ullAvailPhys; + /// + /// The current committed memory limit for the system or the current process, whichever is smaller, in bytes. + /// + public ulong ullTotalPageFile; + /// + /// The maximum amount of memory the current process can commit, in bytes. + /// This value is equal to or smaller than the system-wide available commit value. + /// + public ulong ullAvailPageFile; + /// + /// The size of the user-mode portion of the virtual address space of the calling process, in bytes. + /// + public ulong ullTotalVirtual; + /// + /// The amount of unreserved and uncommitted memory currently in the user-mode portion + /// of the virtual address space of the calling process, in bytes. + /// + public ulong ullAvailVirtual; + /// + /// Reserved. This value is always 0. + /// + public ulong ullAvailExtendedVirtual; + } + + /// + /// Retrieves information about the system's current usage of both physical and virtual memory. + /// + /// A pointer to a structure that receives the memory status information. + /// If the function succeeds, the return value is true. If the function fails, the return value is false. + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + public static extern bool GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); + + } +} \ No newline at end of file diff --git a/advchksys/src/AdvChkSys/advchksys.md b/advchksys/src/AdvChkSys/advchksys.md new file mode 100644 index 0000000..537da30 --- /dev/null +++ b/advchksys/src/AdvChkSys/advchksys.md @@ -0,0 +1,338 @@ +AdvChkSys API Documentation +Overview +AdvChkSys is a high-performance, extensible chunked world management library for .NET. It provides efficient memory management, chunk loading/unloading, resource tracking, event hooks, serialization, and optional constraints for 2D and 3D chunk-based worlds. The system is suitable for games, simulations, and voxel engines. + +Namespaces +AdvChkSys +AdvChkSys.Chunk +AdvChkSys.Manager +AdvChkSys.Interfaces +AdvChkSys.Events +AdvChkSys.Resources +AdvChkSys.Constraints +AdvChkSys.Serialization +AdvChkSys.Threading +AdvChkSys.Util +AdvChkSys (Root) +public static class AdvChkSys +Entry point and static facade for the AdvChkSys library. + +Properties +string Version +The current version of the AdvChkSys library. +Methods +WorldConstraints CreateDefaultConstraints() +Creates a new WorldConstraints object with default settings. + +ChunkManager2D Create2DManager(WorldConstraints? constraints = null) +Creates a new 2D chunk manager with optional constraints. + +ChunkManager3D Create3DManager(WorldConstraints? constraints = null) +Creates a new 3D chunk manager with optional constraints. + +bool SelfTest() +Performs a basic self-test of the AdvChkSys core functionality. Returns true if all core systems are operational. + +AdvChkSys.Chunk +public class Chunk2D : IChunk +Represents a 2D chunk of data in the world. Supports all-air singleton for memory efficiency and chunk array pooling. + +Constructors +Chunk2D(int x, int y, int width, int height, bool isAllAir = false) +Static Methods +Chunk2D AllAir(int x, int y, int width, int height) +Returns a singleton all-air chunk for the given size and position. +Properties +int X +The chunk's X position in chunk-space coordinates. + +int Y +The chunk's Y position in chunk-space coordinates. + +int Width +The width of the chunk (in cells/tiles/units). + +int Height +The height of the chunk (in cells/tiles/units). + +Dictionary Metadata +Metadata dictionary for arbitrary chunk information (e.g., biome, tags). + +bool IsAllAir +Returns true if this chunk is the all-air singleton. + +Indexers +T this[int localX, int localY] +Gets or sets the value at the given local chunk coordinates. +Methods +void Fill(T value) +Fills the chunk with a specified value (no-op for all-air). + +T[,]? GetDataArray() +Returns the underlying data array (for pooling). + +void ReleaseDataArray() +Releases the data array back to the pool (for chunk manager). + +public class Chunk3D : IChunk +Represents a 3D chunk of data in the world. Perspective-agnostic and supports arbitrary data types and metadata. + +Constructors +Chunk3D(int x, int y, int z, int width, int height, int depth) +Properties +int X +The chunk's X position in chunk-space coordinates. + +int Y +The chunk's Y position in chunk-space coordinates. + +int Z +The chunk's Z position in chunk-space coordinates. + +int Width +The width of the chunk (in cells/tiles/units). + +int Height +The height of the chunk (in cells/tiles/units). + +int Depth +The depth of the chunk (in cells/tiles/units). + +Dictionary Metadata +Metadata dictionary for arbitrary chunk information (e.g., biome, tags). + +Indexers +T this[int localX, int localY, int localZ] +Gets or sets the value at the given local chunk coordinates. +Methods +void Fill(T value) +Fills the chunk with a specified value. +AdvChkSys.Manager +public class ChunkManager2D : IChunkManager +Manages 2D chunks in memory. Handles loading, unloading, and access. Uses an LRU cache for memory efficiency. + +Constructors +ChunkManager2D(WorldConstraints? constraints = null, int capacity = 4096) +Methods +bool IsChunkLoaded(int x, int y) +Returns true if the chunk at (x, y) is loaded. + +Chunk2D? GetChunk(int x, int y) +Gets the chunk at (x, y) if loaded, or null if not. + +Chunk2D LoadOrCreateChunk(int x, int y, int width, int height) +Loads or creates a chunk at (x, y) with the given size. + +Task> LoadOrCreateChunkAsync(int x, int y, int width, int height) +Loads or creates a chunk asynchronously. + +bool UnloadChunk(int x, int y) +Unloads (removes) the chunk at (x, y) if loaded. + +Task UnloadChunkAsync(int x, int y) +Unloads the chunk asynchronously. + +IEnumerable> GetAllChunks() +Enumerates all loaded chunks. + +public class ChunkManager3D : IChunkManager +Manages 3D chunks in memory. Handles loading, unloading, and access. + +Constructors +ChunkManager3D(WorldConstraints? constraints = null, int capacity = 4096) +Methods +bool IsChunkLoaded(int x, int y, int z) +Returns true if the chunk at (x, y, z) is loaded. + +Chunk3D? GetChunk(int x, int y, int z) +Gets the chunk at (x, y, z) if loaded, or null if not. + +Chunk3D LoadOrCreateChunk(int x, int y, int z, int width, int height, int depth) +Loads or creates a chunk at (x, y, z) with the given size. + +bool UnloadChunk(int x, int y, int z) +Unloads (removes) the chunk at (x, y, z) if loaded. + +IEnumerable> GetAllChunks() +Enumerates all loaded chunks. + +AdvChkSys.Interfaces +public interface IChunk +Represents a generic chunk in the world. Provides basic properties for position, size, and metadata. + +Properties +int X { get; } +int Y { get; } +int Width { get; } +int Height { get; } +Dictionary Metadata { get; } +public interface IChunkManager +Interface for managing chunks in memory. Provides methods for loading, unloading, and accessing chunks. + +Methods +bool IsChunkLoaded(int x, int y) +IChunk? GetChunk(int x, int y) +IChunk LoadOrCreateChunk(int x, int y, int width, int height) +bool UnloadChunk(int x, int y) +IEnumerable GetAllChunks() +AdvChkSys.Events +public static class ChunkEvents +Provides events for chunk lifecycle operations such as loading, unloading, saving, and more. Thread-safe event subscription and invocation. + +Events +event Action ChunkLoaded +Occurs when a chunk has been loaded into memory. + +event Action ChunkLoading +Occurs when a chunk is about to be loaded into memory. + +event Action ChunkUnloading +Occurs when a chunk is about to be unloaded from memory. + +event Action ChunkUnloaded +Occurs when a chunk has been unloaded from memory. + +event Action ChunkSaving +Occurs when a chunk is about to be saved. + +event Action ChunkSaved +Occurs when a chunk has been saved. + +Methods +void OnChunkLoading(IChunk chunk) +Raises the ChunkLoading event. + +void OnChunkLoaded(IChunk chunk) +Raises the ChunkLoaded event. + +void OnChunkUnloading(IChunk chunk) +Raises the ChunkUnloading event. + +void OnChunkUnloaded(IChunk chunk) +Raises the ChunkUnloaded event. + +void OnChunkSaving(IChunk chunk) +Raises the ChunkSaving event. + +void OnChunkSaved(IChunk chunk) +Raises the ChunkSaved event. + +AdvChkSys.Resources +public static class ChunkResourceManager +Manages allocation and release of chunk resources in memory. Can be extended to track resource usage, pooling, or implement custom memory strategies. + +Methods +void AllocateChunk(IChunk chunk) +Called when a chunk is allocated/loaded into memory. Tracks the chunk and can be extended for pooling or resource limits. + +void ReleaseChunk(IChunk chunk) +Called when a chunk is released/unloaded from memory. Removes the chunk from tracking and can be extended for pooling or cleanup. + +int AllocatedChunkCount +Gets the current number of allocated chunks. + +void Clear() +Clears all tracked chunks (for diagnostics or shutdown). + +AdvChkSys.Constraints +public class WorldConstraints +Represents constraints and limits for the chunk world. Can be used to restrict world size, chunk counts, and other resource limits. + +Properties +int? MinChunkX +The minimum allowed chunk X coordinate (inclusive). + +int? MaxChunkX +The maximum allowed chunk X coordinate (inclusive). + +int? MinChunkY +The minimum allowed chunk Y coordinate (inclusive). + +int? MaxChunkY +The maximum allowed chunk Y coordinate (inclusive). + +int? MaxLoadedChunks +The maximum number of chunks allowed to be loaded in memory at once. + +Methods +bool IsWithinBounds(int chunkX, int chunkY) +Checks if the given chunk coordinates are within the allowed world bounds. + +bool IsWithinChunkLimit(int loadedChunkCount) +Checks if the current number of loaded chunks is within the allowed limit. + +AdvChkSys.Serialization +public static class ChunkSerializer +Provides serialization and deserialization for chunk instances. Supports Chunk2D and Chunk3D with primitive types (e.g., byte, int, float). + +Methods +byte[] Serialize2D(Chunk2D chunk) where T : struct +Serializes a Chunk2D to a byte array. + +Chunk2D Deserialize2D(byte[] data) where T : struct +Deserializes a Chunk2D from a byte array. + +byte[] Serialize3D(Chunk3D chunk) where T : struct +Serializes a Chunk3D to a byte array. + +Chunk3D Deserialize3D(byte[] data) where T : struct +Deserializes a Chunk3D from a byte array. + +AdvChkSys.Threading +public static class ChunkTaskScheduler +Provides utilities for running chunk-related tasks asynchronously. Supports scheduling, cancellation, and custom task options. + +Methods +Task RunAsync(Action action, CancellationToken cancellationToken = default) +Runs the given action asynchronously on the thread pool. + +Task RunAsync(Func func, CancellationToken cancellationToken = default) +Runs the given function asynchronously and returns a result. + +Task RunAsync(Func func, CancellationToken cancellationToken = default) +Runs the given asynchronous function. + +Task RunAsync(Func> func, CancellationToken cancellationToken = default) +Runs the given asynchronous function and returns a result. + +AdvChkSys.Util +public class LRUCache where TKey : notnull +Thread-safe LRU (Least Recently Used) cache. Used for chunk memory management. + +Constructors +LRUCache(int capacity) +Methods +bool TryGet(TKey key, out TValue value) +Tries to get a value by key and marks it as recently used. + +void Add(TKey key, TValue value, Action? onEvict = null) +Adds a value to the cache, evicting the least recently used if over capacity. + +bool Remove(TKey key, out TValue? value) +Removes a value by key. + +Properties +IEnumerable Values +Enumerates all values in the cache. + +int Count +Gets the number of items in the cache. + +Python Bindings (src/bindings/python/advchksys.py) +Provides Python access to AdvChkSys via .NET interop. + +Functions +get_version() +Returns the AdvChkSys library version. + +create_2d_manager(constraints=None) +Creates a 2D chunk manager (byte type). + +create_3d_manager(constraints=None) +Creates a 3D chunk manager (byte type). + +create_constraints(min_x=None, max_x=None, min_y=None, max_y=None, max_loaded=None) +Creates a WorldConstraints object. + +Summary +AdvChkSys provides a modular, high-performance, and extensible API for chunked world management in .NET, with robust support for memory pooling, resource tracking, event hooks, serialization, constraints, and async operations. It is suitable for games, simulations, and any application requiring efficient spatial partitioning and management of large worlds. \ No newline at end of file diff --git a/advchksys/src/bindings/python/README.md b/advchksys/src/bindings/python/README.md new file mode 100644 index 0000000..2f12729 --- /dev/null +++ b/advchksys/src/bindings/python/README.md @@ -0,0 +1,36 @@ +# AdvChkSys Python Bindings + +These bindings allow you to use the AdvChkSys C# chunk system from Python via [pythonnet](https://github.com/pythonnet/pythonnet). + +## Requirements + +- .NET 6.0+ or .NET Core 3.1+ (to build AdvChkSys) +- Python 3.8+ +- `pythonnet` (`pip install pythonnet`) + +## Usage + +1. **Build the AdvChkSys C# library** (DLL must be present in `src/AdvChkSys/bin/Debug/netstandard2.1/AdvChkSys.dll`). + +2. **Install pythonnet:** + ```bash + pip install pythonnet + ``` + +3. **Use the bindings in Python:** + ```python + from advchksys import get_version, create_2d_manager, create_constraints + + print("AdvChkSys version:", get_version()) + + constraints = create_constraints(min_x=0, max_x=10, min_y=0, max_y=10, max_loaded=100) + manager = create_2d_manager(constraints) + chunk = manager.LoadOrCreateChunk(1, 1, 16, 16) + chunk[0, 0] = 42 + print("Chunk value at (0,0):", chunk[0, 0]) + ``` + +## Notes + +- You can access all public methods and properties of the C# classes. +- For advanced usage, see the [pythonnet documentation](https://pythonnet.github.io/pythonnet/). diff --git a/advchksys/src/bindings/python/advchksys.py b/advchksys/src/bindings/python/advchksys.py new file mode 100644 index 0000000..5969f3c --- /dev/null +++ b/advchksys/src/bindings/python/advchksys.py @@ -0,0 +1,71 @@ +import os +import sys + +import clr # type: ignore + +# Path to the compiled AdvChkSys .NET DLL (adjust as needed) +DLL_PATH = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "..", + "..", + "AdvChkSys", + "bin", + "Debug", + "netstandard2.1", + "AdvChkSys.dll", + ) +) + +if not os.path.exists(DLL_PATH): + raise FileNotFoundError(f"Could not find AdvChkSys.dll at {DLL_PATH}") + +sys.path.append(os.path.dirname(DLL_PATH)) +clr.AddReference("AdvChkSys") + +# Import .NET types + +from System import Byte # type: ignore # noqa: E402 + +import AdvChkSys # type: ignore # noqa: E402 +import AdvChkSys.Manager # type: ignore # noqa: E402 +import AdvChkSys.Constraints # type: ignore # noqa: E402 + + +def get_version(): + """Return the AdvChkSys library version.""" + return AdvChkSys.AdvChkSys.Version # type: ignore + + +def create_2d_manager(constraints=None): + """Create a 2D chunk manager (byte type).""" + if constraints is None: + constraints = AdvChkSys.Constraints.WorldConstraints() # type: ignore + # Use generic type with System.Byte + return AdvChkSys.Manager.ChunkManager2D[Byte](constraints) # type: ignore + + +def create_3d_manager(constraints=None): + """Create a 3D chunk manager (byte type).""" + if constraints is None: + constraints = AdvChkSys.Constraints.WorldConstraints() # type: ignore + # Use generic type with System.Byte + return AdvChkSys.Manager.ChunkManager3D[Byte](constraints) # type: ignore + + +def create_constraints( + min_x=None, max_x=None, min_y=None, max_y=None, max_loaded=None +): + """Create a WorldConstraints object.""" + wc = AdvChkSys.Constraints.WorldConstraints() # type: ignore + if min_x is not None: + wc.MinChunkX = min_x + if max_x is not None: + wc.MaxChunkX = max_x + if min_y is not None: + wc.MinChunkY = min_y + if max_y is not None: + wc.MaxChunkY = max_y + if max_loaded is not None: + wc.MaxLoadedChunks = max_loaded + return wc diff --git a/docs/conf.py b/docs/conf.py index 849a12e..0cff05d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,6 +1,13 @@ -""" Configuration file for Sphinx documentation""" +"""Sphinx configuration file.""" +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) + +# Configuration file for Sphinx documentation builder. PROJECT = "sandpypi" +PROJECT_NAME = "Sandpypi" COPYRIGHT = "2024, stan44" AUTHOR = "stan44" VERSION = "0.1.0" @@ -12,11 +19,18 @@ extensions = [ "sphinx.ext.viewcode", "sphinx.ext.githubpages", "sphinx_rtd_theme", + "sphinx_rtd_dark_mode", ] templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +DEFAULT_DARK_MODE = True HTML_THEME = "sphinx_rtd_theme" html_static_path = ["_static"] -# html_logo = "_static/logo.png" # Add your project's logo +html_css_files = ["custom.css"] +# html_logo = "_static/logo.png" # No logo to use. + +SYNTAX_HIGHLIGHTING = True +PYGMENTS_STYLE = "monokai" +PYGMENTS_DARK_STYLE = "monokai" diff --git a/docs/index.rst b/docs/index.rst index f7f4fee..c6e6ab5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,13 +9,15 @@ Welcome to Sandpypi Documentation guides/user_guide architecture/overview api/modules - + source/modules + source/modindex + Development Standards ------------------ This project follows these development standards: -* Black code formatting (88 characters line length) +* Black/flake8 code formatting (79 characters line length) * Type hints for Python functions * Google style docstrings * Particle simulation physics @@ -23,7 +25,5 @@ This project follows these development standards: Quick Links ---------- - -* :ref:`genindex` * :ref:`modindex` * :ref:`search` diff --git a/mypy.ini b/mypy.ini deleted file mode 100644 index f9cdb33..0000000 --- a/mypy.ini +++ /dev/null @@ -1,4 +0,0 @@ -[mypy] -namespace_packages = True -explicit_package_bases = True -python_version = 3.13 diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..7916675 --- /dev/null +++ b/python/README.md @@ -0,0 +1,5 @@ +# Legacy Python + +The original Python sandbox code now lives under `python/src/`. + +This folder is kept for historical reference, parity checks, and continued Python-side experiments while the main active port lives in the C# projects at repo root. diff --git a/__init__.py b/python/__init__.py similarity index 88% rename from __init__.py rename to python/__init__.py index be9872a..172917c 100644 --- a/__init__.py +++ b/python/__init__.py @@ -7,6 +7,11 @@ This package provides modules for particle physics simulation and rendering: - rendering.rendering: Display and visualization components """ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "python")) + from src.config.settings import ( cProfile, engine_settings, diff --git a/plansandideas/Coding optimization plan.md b/python/plansandideas/Coding optimization plan.md similarity index 100% rename from plansandideas/Coding optimization plan.md rename to python/plansandideas/Coding optimization plan.md diff --git a/plansandideas/REORGANIZATION.md b/python/plansandideas/REORGANIZATION.md similarity index 100% rename from plansandideas/REORGANIZATION.md rename to python/plansandideas/REORGANIZATION.md diff --git a/python/plansandideas/particles.json b/python/plansandideas/particles.json new file mode 100644 index 0000000..454da41 --- /dev/null +++ b/python/plansandideas/particles.json @@ -0,0 +1,476 @@ +{ + "sand": { + "name": "Sand", + "size": 1, + "hardness": 0.5, + "color": [255, 255, 0, 255], + "velocity": 0.5, + "wind": 1, + "mass": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "flamability": 0.8, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0, + "pressure": 0, + "melt": "molten-Glass", + "melt_temperature": 1700, + "conductive": false, + "liquid": false, + "solid": true, + "is_gas": false + }, + "water": { + "name": "Water", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [0, 0, 255, 255], + "mass": 1, + "flamability": 0, + "temperature": 22, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 1, + "viscosity": 1, + "pressure": 0.5, + "evaporate": "steam", + "evaporate_temperature": 100, + "freeze": "ice", + "freeze_temperature": 0, + "conductive": true, + "liquid": true, + "solid": false, + "is_gas": false + }, + "steam": { + "name": "Steam", + "size": 1, + "hardness": 0.0, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [255, 255, 255, 255], + "mass": 0.01, + "flamability": 0, + "temperature": 100, + "solidify_temperature": 98, + "solidify": "water", + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": false, + "is_gas": true, + "conductive": false + }, + "ice": { + "name": "Ice", + "size": 1, + "hardness": 1000, + "velocity": 0.0, + "conductivity": 0, + "heat_capacity": 0, + "color": [75, 75, 170, 255], + "mass": 1, + "flamability": 0.0, + "temperature": 0, + "melt": "water", + "melt_temperature": 0.05, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 1, + "viscosity": 1, + "liquid": false, + "solid": true, + "is_gas": false + }, + "mud": { + "name": "Mud", + "size": 1, + "hardness": 0.4, + "velocity": 0.5, + "conductivity": 1, + "heat_capacity": 1, + "color": [139, 69, 19, 255], + "mass": 0.5, + "flamability": 0, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 1, + "liquid": false, + "solid": true, + "is_gas": false + }, + "fire": { + "name": "Fire", + "size": 1, + "hardness": 0.1, + "velocity": 0.1, + "conductivity": 0, + "heat_capacity": 1, + "color": [255, 0, 0, 255], + "mass": 0.1, + "flamability": 1, + "temperature": 800, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.1, + "viscosity": 0.1, + "liquid": false, + "solid": false, + "is_gas": true + }, + "smoke": { + "name": "Smoke", + "size": 1, + "hardness": 0.1, + "velocity": 0.07, + "conductivity": 0, + "heat_capacity": 1, + "color": [115, 113, 95, 255], + "mass": 0.01, + "flamability": 0, + "temperature": 85, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.4, + "viscosity": 0.1, + "lifetime": 90, + "liquid": false, + "solid": false, + "is_gas": true + }, + "wall": { + "name": "Wall", + "size": 1, + "hardness": 1000, + "velocity": 0.0, + "conductivity": 0, + "heat_capacity": 0, + "color": [75, 75, 75, 255], + "mass": 1, + "flamability": 0, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 1, + "viscosity": 1, + "liquid": false, + "solid": true, + "is_gas": false + }, + "dirt": { + "name": "Dirt", + "size": 1, + "hardness": 0.5, + "velocity": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "color": [139, 69, 19, 255], + "mass": 0.5, + "flamability": 0, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": true, + "is_gas": false + }, + "stone": { + "name": "Stone", + "size": 1, + "hardness": 0.7, + "velocity": 1.5, + "conductivity": 0, + "heat_capacity": 0, + "color": [128, 128, 128, 220], + "mass": 1, + "flamability": 0, + "melt": "molten-Stone", + "melt_temperature": 800, + "solidify": "stone", + "solidify_temperature": 799, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": true, + "is_gas": false + }, + "snow": { + "name": "Snow", + "size": 1, + "hardness": 0.1, + "velocity": 0.2, + "conductivity": 1, + "heat_capacity": 1, + "color": [255, 255, 255, 255], + "mass": 0.01, + "flamability": 0, + "melt": "water", + "melt_temperature": 1, + "temperature": 0, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.1, + "viscosity": 0.01, + "liquid": false, + "solid": true, + "is_gas": false + }, + "wood": { + "name": "Wood", + "size": 1, + "hardness": 0.5, + "velocity": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "color": [139, 69, 19, 255], + "mass": 0.5, + "flamability": 0.8, + "burning_temperature": 250, + "burning_rate": 0.01, + "burning_color": [255, 69, 19, 255], + "burning": false, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": true, + "is_gas": false + }, + "burning-wood": { + "name": "Burning Wood", + "size": 1, + "hardness": 0.5, + "velocity": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "color": [255, 69, 19, 255], + "mass": 0.5, + "flamability": 0.8, + "temperature": 251, + "burning": true, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": true, + "is_gas": false + }, + "air": { + "name": "Air", + "size": 1, + "hardness": 0.0, + "velocity": 0.0, + "conductivity": 0, + "heat_capacity": 1, + "color": [255, 255, 255, 25], + "mass": 0.0, + "flamability": 0, + "temperature": 0, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.0, + "viscosity": 0.0, + "liquid": false, + "solid": false, + "is_gas": true + }, + "lava": { + "name": "Lava", + "size": 1, + "hardness": 0.2, + "velocity": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "color": [255, 45, 60, 255], + "mass": 0.3, + "flamability": 0, + "temperature": 1400, + "solidify": "molten-rock", + "solidify_temperature": 799, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.8, + "viscosity": 0.8, + "liquid": true, + "solid": false, + "is_gas": false + }, + "rock": { + "name": "Rock", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [128, 128, 128, 255], + "mass": 0.8, + "flamability": 0, + "melt": "molten-rock", + "melt_temperature": 600, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": true, + "is_gas": false + }, + "molten-rock": { + "name": "Molten Rock", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [255, 140, 0, 255], + "mass": 0.8, + "flamability": 0, + "temperature": 600, + "melt": "lava", + "melt_temperature": 1300, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0,0,0], + "friction": 0.8, + "viscosity": 0.8, + "liquid": true, + "solid": false, + "is_gas": false, + "solidify": "rock", + "solidify_temperature": 200 + }, + "molten_stone": { + "name": "Molten Stone", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [255, 140, 0, 255], + "mass": 0.8, + "flamability": 0, + "temperature": 1200, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.8, + "viscosity": 0.8, + "liquid": true, + "solid": false, + "is_gas": false, + "solidify": "stone", + "solidify_temperature": 800 + }, + "molten_glass": { + "name": "Molten Glass", + "size": 1, + "hardness": 0.2, + "velocity": 0.4, + "conductivity": 0.8, + "heat_capacity": 1, + "color": [255, 200, 150, 200], + "mass": 0.6, + "flamability": 0, + "temperature": 600, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.7, + "viscosity": 0.9, + "liquid": true, + "solid": false, + "is_gas": false, + "solidify": "glass", + "solidify_temperature": 599 + }, + "glass": { + "name": "Glass", + "size": 1, + "hardness": 0.2, + "velocity": 0.4, + "conductivity": 0, + "heat_capacity": 1, + "color": [50, 45, 255, 100], + "mass": 0.6, + "flamability": 0, + "temperature": 20, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.7, + "viscosity": 0.9, + "liquid": false, + "solid": true, + "is_gas": false, + "melt": "molten-glass", + "melt_temperature": 600 + }, + "plasma": { + "name": "Plasma", + "size": 1, + "hardness": 0.0, + "velocity": 0.0, + "conductivity": 0, + "heat_capacity": 1, + "color": [255, 100, 200, 255], + "mass": 0.0, + "flamability": 0, + "temperature": 3600, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.0, + "viscosity": 0.0, + "liquid": false, + "solid": false, + "is_gas": true + }, + "wind": { + "name": "Wind", + "color": [200, 200, 255, 128], + "mass": 0.01, + "is_wind": true, + "wind_strength": 2.0, + "wind_direction": [1, 0], + "radius": 50, + "special": true + } + +} + diff --git a/src/rendering/rendering.py b/python/plansandideas/rendering.py similarity index 87% rename from src/rendering/rendering.py rename to python/plansandideas/rendering.py index 34346af..383e0c6 100644 --- a/src/rendering/rendering.py +++ b/python/plansandideas/rendering.py @@ -2,49 +2,29 @@ #File Name: rendering.py Rendering class for the particle simulation. -This class is responsible for rendering the particles, UI elements, and debug -information on the screen. -It handles the setup of the display, pre-rendering -of static UI elements, -and the drawing of particles, buttons, and debug overlays. +This class is responsible for rendering the particles, UI elements, and debug information on the screen. It handles the setup of the display, pre-rendering of static UI elements, and the drawing of particles, buttons, and debug overlays. -The `draw_particles` function is the main method for rendering the particles -on the screen. It takes the particle data, active particles, particle size, -and particle colors as input, -and renders the particles on the `particle_surface`. The `particle_surface` is -then blitted onto the main screen. +The `draw_particles` function is the main method for rendering the particles on the screen. It takes the particle data, active particles, particle size, and particle colors as input, and renders the particles on the `particle_surface`. The `particle_surface` is then blitted onto the main screen. -The `draw_zoom_window` function is used to render a zoomed-in view of the -particles around the mouse cursor. -It creates a separate surface for the zoomed-in view and returns it. +The `draw_zoom_window` function is used to render a zoomed-in view of the particles around the mouse cursor. It creates a separate surface for the zoomed-in view and returns it. -The `draw_debug_overlay` responsible for rendering the debug information, -such as FPS, mouse position, and particle information, on the screen. +The `draw_debug_overlay` function is responsible for rendering the debug information, such as FPS, mouse position, and particle information, on the screen. -The `draw_buttons` function handles the rendering of the category buttons, -particle buttons, and other UI elements -like the clear screen and settings buttons. +The `draw_buttons` function handles the rendering of the category buttons, particle buttons, and other UI elements like the clear screen and settings buttons. -The `render_brush_cursor` draw a visual indicator for the current brush size. +The `render_brush_cursor` function is used to draw a visual indicator for the current brush size. -The `draw_brush_size_slider` renders a slider for adjusting the brush size. +The `draw_brush_size_slider` function renders a slider for adjusting the brush size. -The `draw_settings_menu` settings menu that can be displayed on the screen. +The `draw_settings_menu` function creates a settings menu surface that can be displayed on the screen. -The `clear_screen` reset the simulation grid and clear the display surfaces. -The `setup_gpu_rendering` placeholder for GPU-based rendering setup. +The `clear_screen` function is used to reset the simulation grid and clear the display surfaces. """ -from ..config.settings import ( - engine_settings, - particle_properties, - pygame, - random, -) +from settings import engine_settings, particle_properties, pygame, random class Rendering: - """Main rendering system""" def __init__(self, width, height): # self.setup_gpu_rendering() @@ -53,27 +33,12 @@ class Rendering: self.background.fill((0, 0, 0)) self.width = width self.height = height - self.particle_count = 0 self.particle_colors = {} self.particle_properties = particle_properties self.particle_surface = pygame.Surface( (width, height), pygame.SRCALPHA ) - self.zoom_window_surface = pygame.Surface((200, 200), pygame.SRCALPHA) - # self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) - self.particle_surface = pygame.Surface( - (self.width, self.height), pygame.SRCALPHA - ) - self.settings_menu_surface = pygame.Surface( - (300, 300), pygame.SRCALPHA - ) - self.brush_cursor_surface = pygame.Surface((10, 10), pygame.SRCALPHA) - self._last_debug_info = None - self.buttons = {} - self.button_height = 30 - self.button_width = 100 - self.clear_screen_button = pygame.Rect(10, 10, 100, 30) - self.settings_button = pygame.Rect(10, 50, 100, 30) + self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) self.cached_fonts = { "debug": pygame.font.SysFont(None, 24), "button": pygame.font.SysFont(None, 20), @@ -84,6 +49,7 @@ class Rendering: "zoom_window": pygame.font.SysFont(None, 20), "zoom_window_text": pygame.font.SysFont(None, 20), } + # Pre-render static UI elements self.button_surfaces = {} @@ -113,14 +79,13 @@ class Rendering: self.setup_static_ui() def setup_gpu_rendering(self): - """Initialize OpenGL context""" + # Initialize OpenGL context pygame.display.gl_set_attribute(pygame.GL_ACCELERATED_VISUAL, 1) self.screen = pygame.display.set_mode( (self.width, self.height), pygame.OPENGL | pygame.DOUBLEBUF ) def setup_static_ui(self): - """Setup static UI elements""" for category in self.categories: surf = pygame.Surface((80, 25)) surf.fill((150, 150, 150)) @@ -131,7 +96,7 @@ class Rendering: self.button_surfaces[category] = surf def setup_category_menu(self): - """Setup category menu""" + # Category buttons at the top x_offset = self.width - 350 y_offset = 10 for category in self.categories: @@ -140,7 +105,6 @@ class Rendering: x_offset += 90 def load_buttons(self): - """Load buttons""" x_offset = 10 y_offset = 10 @@ -156,8 +120,8 @@ class Rendering: def draw_particles( self, particles, active_particles, particle_size, particle_colors - ): - """Draw particles""" + ): # this is the function that draws the particles + # self.particle_surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA) self.particle_surface.fill((0, 0, 0, 0)) particle_batches = {} @@ -237,8 +201,8 @@ class Rendering: """#Potentially for future for p_type, rects in particle_batches.items(): color = particle_colors.get(p_type, (255, 255, 255)) - rect = (x * particle_size, y * particle_size, - particle_size, particle_size) + rect = (x * particle_size, y * particle_size, + particle_size, particle_size) if len(rects) > 1: pygame.draw.rect(self.particle_surface, color, rect) else: @@ -254,9 +218,8 @@ class Rendering: particle_colors, mouse_pos, zoom_factor=4, - ): - """Draw zoom window""" - print(f"Drawing zoom window.{mouse_pos}") + ): # this is the function that draws the zoom window + print(f"Drawing zoom window.") zoom_size = 100 # Size of zoom window zoom_surface = pygame.Surface((zoom_size, zoom_size)) zoom_surface.fill((0, 0, 0)) @@ -308,7 +271,6 @@ class Rendering: return " | ".join(info) return "None" - """ def draw_wind_overlay(self, wind_zones): wind_surface = pygame.Surface( (self.width, self.height), pygame.SRCALPHA @@ -317,7 +279,7 @@ class Rendering: for zone in wind_zones: # Draw wind direction arrows x, y = zone["x"], zone["y"] - # radius = zone["radius"] + radius = zone["radius"] strength = zone["strength"] direction = zone["direction"] @@ -335,9 +297,8 @@ class Rendering: # Draw arrow head pygame.draw.circle(wind_surface, arrow_color, (int(x), int(y)), 5) - self.screen.blit(wind_surface, (0, 0))""" + self.screen.blit(wind_surface, (0, 0)) - """ def draw_pressure_overlay(self, particles, active_particles): pressure_surface = pygame.Surface( (self.width, self.height), pygame.SRCALPHA @@ -375,9 +336,8 @@ class Rendering: ), ) - self.screen.blit(pressure_surface, (0, 0))""" + self.screen.blit(pressure_surface, (0, 0)) - """ def draw_temperature_overlay(self, particles, active_particles): temperature_surface = pygame.Surface( (self.width, self.height), pygame.SRCALPHA @@ -415,11 +375,9 @@ class Rendering: ), ) - self.screen.blit(temperature_surface, (0, 0))""" + self.screen.blit(temperature_surface, (0, 0)) def draw_debug_overlay(self, fps, sim): - """Debugger moving to debugger_system.py""" - if ( not engine_settings["enable_fps"] and not engine_settings["enable_debug"] @@ -448,6 +406,7 @@ class Rendering: ): self._last_debug_info = current_info self.debug_surface.fill((0, 0, 0, 0)) + font = self.cached_fonts["debug"] y_offset = 10 @@ -475,8 +434,7 @@ class Rendering: # Single blit of cached surface self.screen.blit(self.debug_surface, (0, 0)) - def draw_buttons(self): - """Draws the buttons on the screen.""" + def draw_buttons(self): # this is the function that draws the buttons self.buttons = {} # Draw category buttons on right @@ -519,6 +477,8 @@ class Rendering: y_offset += 30 # Stack buttons vertically + final_y_offset = y_offset + 10 + # Draw clear screen button if "clear" not in self.button_surfaces: clear_surface = pygame.Surface((80, 25)) @@ -548,8 +508,8 @@ class Rendering: ) def render_brush_cursor(self, x, y, radius): - """Draw outline circle""" if engine_settings["enable_cursor"]: + # Draw outline circle pygame.draw.circle(self.screen, (255, 255, 255), (x, y), radius, 1) # Draw slightly transparent fill cursor_surface = pygame.Surface( @@ -560,8 +520,10 @@ class Rendering: ) self.screen.blit(cursor_surface, (x - radius, y - radius)) - def draw_brush_size_slider(self, brush_size): - """Draw the slider for brush size""" + def draw_brush_size_slider( + self, brush_size + ): # this is the function that draws the brush size slider + # Draw the slider for brush size pygame.draw.rect(self.screen, (255, 255, 255), (500, 10, 100, 20)) pygame.draw.rect(self.screen, (0, 0, 0), (500, 10, 100, 20), 2) pygame.draw.rect( @@ -575,7 +537,6 @@ class Rendering: self.screen.blit(label, (500, 10)) def draw_settings_menu(self): - """Draw the settings menu""" settings_surface = pygame.Surface((300, 400)) settings_surface.fill((50, 50, 50)) @@ -601,8 +562,8 @@ class Rendering: return settings_surface - def clear_screen(self, sim): - """Store current particle type""" + def clear_screen(self, sim): # this is the function that clears the screen + # Store current particle type current_type = sim.current_particle_type self.particle_count = 0 # Reset simulation grid while preserving particle type diff --git a/python/plansandideas/settings.py b/python/plansandideas/settings.py new file mode 100644 index 0000000..998b3d3 --- /dev/null +++ b/python/plansandideas/settings.py @@ -0,0 +1,48 @@ +""" +#File Name: settings.py + +Global settings and imports for the project. + +This module defines various settings for the game engine, such as enabling or disabling the cursor, glow effect, gas effect, debug mode, and FPS display. It also provides a function to load particle properties from a JSON file. + +The `engine_settings` dictionary contains the configurable settings for the game engine. These settings can be used to customize the behavior of the game. + +The `load_particle_properties()` function attempts to load particle properties from a 'particles.json' file. If the file is not found or the JSON data is invalid, it returns an empty dictionary. + +The `particle_properties` variable is initialized by calling `load_particle_properties()` when the module is imported. +""" + +import json +import random +import time + +import numpy as np +import pygame + +engine_settings = { + "pause_sim": True, + "enable_cursor": True, + "enable_glow": False, + "enable_gas_effect": True, + "enable_debug": False, + "enable_fps": True, + "enable_WVisuals": False, + "enable_PVisuals": False, + "enable_TempVisuals": False, + "outerwall": True, + # 'settings': True/False +} + + +# Load particle properties from JSON file +def load_particle_properties(): + try: + with open("particles.json") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + print("Error loading particles.json") + return {} + + +# Load particle properties once when module is imported +particle_properties = load_particle_properties() diff --git a/python/plansandideas/sim.py b/python/plansandideas/sim.py new file mode 100644 index 0000000..a18d8e9 --- /dev/null +++ b/python/plansandideas/sim.py @@ -0,0 +1,930 @@ +""" +#File Name: sim.py +Particle-based Physics Simulation System +====================================== + +This module implements a 2D particle simulation with physics, interactions, and state changes. + +Key Components: +-------------- +1. Particle Class + - Handles individual particle properties and behaviors + - Supports multiple particle types (solid, liquid, gas) + - Manages temperature and state transitions + +2. Simulation Class + - Core simulation engine + - Manages particle creation, movement and interactions + - Handles physics calculations and spatial partitioning +""" + +# Load the imports. Pygame is what makes this even work and so simple may consider other engines for performance depends on learning curve. +from settings import particle_properties, random, time + + +# Load particle properties from json so we know what particles we got and how they should be simulated. +class Particle: + def __init__( + self, + position, + velocity, + mass, + particle_type, + properties, + temperature=20, + ): + self.position = position # (x, y) + self.velocity = velocity # (vx, vy) + self.mass = mass + self.particle_type = particle_type + + # Core properties + self.size = properties.get("size", 1) + self.hardness = properties.get("hardness", 0.5) + self.color = properties.get("color", [255, 255, 255, 255]) + self.temperature = properties.get("temperature", temperature) + + # Physics properties + self.conductivity = properties.get("conductivity", 0) + self.heat_capacity = properties.get("heat_capacity", 1) + self.flamability = properties.get("flamability", 0.0) + self.friction = properties.get("friction", 0.5) + self.viscosity = properties.get("viscosity", 1.0) + self.pressure = properties.get("pressure", 0) + + # State properties + self.liquid = properties.get("liquid", False) + self.solid = properties.get("solid", True) + self.is_gas = properties.get("is_gas", False) + + # Temperature transition properties + self.melt = properties.get("melt", None) + self.melt_temperature = properties.get("melt_temperature", None) + self.solidify = properties.get("solidify", None) + self.solidify_temperature = properties.get( + "solidify_temperature", None + ) + self.evaporate = properties.get("evaporate", None) + self.evaporate_temperature = properties.get( + "evaporate_temperature", None + ) + self.freeze = properties.get("freeze", None) + self.freeze_temperature = properties.get("freeze_temperature", None) + + # Special properties + self.explosive = properties.get("explosive", False) + self.explosion_radius = properties.get("explosion_radius", 0) + self.explosion_color = properties.get("explosion_color", [0, 0, 0]) + + @classmethod + def from_type(cls, position, particle_type, properties): + default_velocity = [0, 0] + default_mass = properties.get("mass", 1.0) + return cls( + position, default_velocity, default_mass, particle_type, properties + ) + + +class Simulation: + # the main class of the simulation. + + def __init__(self, width, height, x=0, y=0): + self.dormant_particles = set() + self.particle_movement_counter = {} + self.DORMANT_THRESHOLD = 10 + self.x = x + self.y = y + self.new_x = 0 + self.new_y = 0 + self.width = width + self.height = height + self.particle_size = 3 + self.particles = [[None for _ in range(height)] for _ in range(width)] + self.particle_count = 0 + self.active_particles = set() + self.cell_size = 32 + self.spatial_grid = {} + self.brush_size = 1 + self.max_brush_size = 20 + self.particle_properties = particle_properties + self.current_particle_type = "sand" + self.gravity = ( + 9.8 # m/s^2, adjustable based on the scale of simulation + ) + self.wind_zones = [] + self.wind = [0.0, 0.0] # Global wind vector (x, y) + self._acceleration_wrapper = None + + def reset_particle_count(self): + self.particle_count = 0 + + def get_cell_key(self, x, y): # this is where we get the cell key. + # Convert coordinates to grid cell + cell_x = x // self.cell_size + cell_y = y // self.cell_size + return (cell_x, cell_y) + + def add_to_spatial_grid( + self, particle, x, y + ): # this is where we add to the spatial grid. + cell_key = self.get_cell_key(x, y) + if cell_key not in self.spatial_grid: + self.spatial_grid[cell_key] = set() + self.spatial_grid[cell_key].add((x, y)) + return cell_key + + def remove_from_spatial_grid( + self, x, y + ): # this is where we remove from the spatial grid. + cell_key = self.get_cell_key(x, y) + if cell_key in self.spatial_grid: + self.spatial_grid[cell_key].discard((x, y)) + return cell_key + + def update_spatial_grid(self): # this is where we update the spatial grid. + """Update spatial grid for optimized collision detection""" + if len(self.active_particles) > 100: + self.spatial_grid = {} + cell_lists = {} + + for x, y in self.active_particles: + cell_key = (x // self.cell_size, y // self.cell_size) + if cell_key not in cell_lists: + cell_lists[cell_key] = [] + cell_lists[cell_key].append((x, y)) + + self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} + + def _check_dormant_state(self, x, y, particle): + key = (x, y) + if particle.particle_type == "wall": + self.dormant_particles.add(key) + return True + + if not hasattr(particle, "last_position"): + particle.last_position = (x, y) + self.particle_movement_counter[key] = 0 + return False + + if particle.last_position == (x, y): + self.particle_movement_counter[key] = ( + self.particle_movement_counter.get(key, 0) + 1 + ) + if self.particle_movement_counter[key] >= self.DORMANT_THRESHOLD: + self.dormant_particles.add(key) + return True + else: + particle.last_position = (x, y) + self.particle_movement_counter[key] = 0 + self.dormant_particles.discard(key) + return False + + def handle_phase_transitions( + self, particle, x, y + ): # this is where we handle all the phase transitions. + """Handle all phase transitions for a particle""" + # Check evaporation + if ( + hasattr(particle, "evaporate_temperature") + and particle.evaporate_temperature is not None + ): + if ( + particle.temperature >= particle.evaporate_temperature + and particle.evaporate + ): + self.transform_particle(x, y, particle.evaporate) + + # Check freezing + if ( + hasattr(particle, "freeze_temperature") + and particle.freeze_temperature is not None + ): + if ( + particle.temperature <= particle.freeze_temperature + and particle.freeze + ): + self.transform_particle(x, y, particle.freeze) + + # Check for melting with proper attribute validation + if ( + hasattr(particle, "melt") + and hasattr(particle, "melt_temperature") + and particle.melt_temperature is not None + ): + if particle.temperature >= particle.melt_temperature: + new_type = particle.melt + if new_type in self.particle_properties: + self.transform_particle(x, y, new_type) + + # Check for solidification with proper attribute validation + if ( + hasattr(particle, "solidify") + and hasattr(particle, "solidify_temperature") + and particle.solidify_temperature is not None + ): + if particle.temperature <= particle.solidify_temperature: + new_type = particle.solidify + if new_type in self.particle_properties: + self.transform_particle(x, y, new_type) + + def handle_particle_interactions( + self, dt + ): # this is where we handle all the particle interactions. + """Handle interactions between different particle types""" + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if not particle: + continue + + # Check neighboring particles + for dx, dy in [ + (-1, 0), + (1, 0), + (0, -1), + (0, 1), + (-1, -1), + (1, -1), + (-1, 1), + (1, 1), + ]: + nx, ny = x + dx, y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + neighbor = self.particles[nx][ny] + if neighbor: + self.process_interaction( + particle, neighbor, x, y, nx, ny + ) + + def process_interaction( + self, particle1, particle2, x1, y1, x2, y2 + ): # this function is part 2 of handle_particle_interactions. + """Process specific interactions between two particles""" + # Water + Sand = Mud + if ( + particle1.particle_type == "water" + and particle2.particle_type == "sand" + or particle2.particle_type == "water" + and particle1.particle_type == "sand" + ): + self.create_mud(x1, y1) + self.particles[x2][y2] = None + self.active_particles.discard((x2, y2)) + + # Lava/Fire effects + if particle1.particle_type in [ + "lava", + "fire", + "flame", + ] or particle2.particle_type in ["lava", "fire", "flame"]: + target = ( + particle2 + if particle1.particle_type in ["lava", "fire", "flame"] + else particle1 + ) + target_x, target_y = ( + (x2, y2) + if particle1.particle_type in ["lava", "fire", "flame"] + else (x1, y1) + ) + + # Water to Steam + if target.particle_type == "water": + self.transform_particle(target_x, target_y, "steam") + + # Wood to Fire + elif target.particle_type == "wood": + if random.random() < 0.3: # 30% chance to ignite + self.transform_particle(target_x, target_y, "fire") + + def create_mud( + self, x, y + ): # this is where we create the mud. probably should be moved to handle_particle_interactions or process_interaction. + """Create mud particle from water and sand interaction""" + if "mud" in self.particle_properties: + properties = self.particle_properties["mud"] + new_particle = Particle.from_type((x, y), "mud", properties) + self.particles[x][y] = new_particle + self.active_particles.add((x, y)) + + def transform_particle( + self, x, y, new_type + ): # this is where we transform the particle. + """Transform a particle into a different type""" + if new_type in self.particle_properties: + properties = self.particle_properties[new_type] + new_particle = Particle.from_type((x, y), new_type, properties) + self.particles[x][y] = new_particle + self.active_particles.add((x, y)) + + def handle_gas_movement( + self, particle, x, y + ): # this is where we handle the gas movement. this function sucks. wip + """Handle gas particle movement""" + if particle.is_gas: + dx = random.uniform(-1, 1) + dy = random.uniform(-2, 0) # Bias upward movement + new_x = int(x + dx) + new_y = int(y + dy) + + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[new_x][new_y] is None: + self.particles[x][y] = None + self.particles[new_x][new_y] = particle + self.active_particles.add((new_x, new_y)) + self.active_particles.discard((x, y)) + + def add_wind_zone(self, x, y): + # Instead of creating particles, store wind zone data + wind_zone = { + "x": x, + "y": y, + "radius": 50, + "strength": 2.0, + "direction": [1, 0], + } + self.wind_zones.append(wind_zone) + + def calculate_forces( + self, particle, x, y + ): # this is where we calculate the forces. + """Calculate net forces acting on a particle.""" + fx, fy = 0.0, 0.0 # Initialize forces + + # Check wind zones + for zone in self.wind_zones: + dx = x - zone["x"] + dy = y - zone["y"] + distance = (dx * dx + dy * dy) ** 0.5 + if distance <= zone["radius"]: + fx += ( + zone["direction"][0] + * zone["strength"] + * (1 - distance / zone["radius"]) + ) + fy += ( + zone["direction"][1] + * zone["strength"] + * (1 - distance / zone["radius"]) + ) + + # Apply wind force + if particle.is_gas: + fx += self.wind[0] * 0.5 + fy += self.wind[1] * 0.5 + else: + fx += self.wind[0] + fy += self.wind[1] + + # Apply drag force + drag = particle.viscosity * -1 + fx += drag * particle.velocity[0] + fy += drag * particle.velocity[1] + + # Check neighboring particles + neighbors = self._get_quick_neighbors(x, y) + for nx, ny in neighbors: + if 0 <= nx < self.width and 0 <= ny < self.height: + neighbor = self.particles[nx][ny] + if neighbor: + self._apply_neighbor_forces(particle, neighbor, fx, fy) + + return fx, fy + + """ + def _process_particle_batch(self, batch, dt): + updates = [] + new_active = set() + + # Filter out dormant particles from the batch + active_batch = [pos for pos in batch if pos not in self.dormant_particles] + + for x, y in active_batch: + particle = self.particles[x][y] + if not particle: + continue + + if particle.particle_type == 'wall': + new_active.add((x, y)) + continue + + # Check if particle should become dormant + if self._check_dormant_state(x, y, particle): + new_active.add((x, y)) + continue + + # physics calculations + fx, fy = self.calculate_forces(particle, x, y) + # Use max() to ensure mass is never zero + mass = max(particle.mass, 0.001) + particle.velocity[0] += (fx / mass) * dt + particle.velocity[1] += (fy / mass) * dt + + new_x = int(x + particle.velocity[0] * dt) + new_y = int(y + particle.velocity[1] * dt) + + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[new_x][new_y] is None: + updates.append((x, y, new_x, new_y, particle)) + new_active.add((new_x, new_y)) + # Wake up neighboring dormant particles + self._wake_neighbors(new_x, new_y) + else: + new_active.add((x, y)) + + # Apply updates and return new active set + for old_x, old_y, new_x, new_y, particle in updates: + self.particles[old_x][old_y] = None + self.particles[new_x][new_y] = particle + particle.position = (new_x, new_y) + + return new_active + """ + + def _get_quick_neighbors(self, x, y): + """Quick neighbor lookup without full spatial grid""" + return [ + (x + dx, y + dy) for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)] + ] + + def _apply_neighbor_forces(self, particle, neighbor, fx, fy): + """Optimized neighbor force calculation""" + if hasattr(neighbor, "temperature") and hasattr( + particle, "temperature" + ): + temp_diff = neighbor.temperature - particle.temperature + fy += temp_diff * 0.05 + + def ignite_particle( + self, particle + ): # this is where we ignite the particle. + """Handle ignition and burning of flammable particles.""" + if hasattr(particle, "flamability") and particle.flamability > 0.5: + if hasattr(particle, "temperature") and particle.temperature > 150: + particle.type = "fire" + particle.temperature += 200 + # Add burning effect for wood + if particle.type == "wood": + particle.burning = True + particle.burn_time = 100 # burn time + + def spread_fire(self): # this is where we spread the fire. + """Spread fire to neighboring particles.""" + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if particle and ( + particle.particle_type == "fire" + or getattr(particle, "burning", False) + ): + # Check all neighboring cells including diagonals + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + nx, ny = x + dx, y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + neighbor = self.particles[nx][ny] + if neighbor and hasattr(neighbor, "flamability"): + if neighbor.particle_type == "wood": + # Higher chance to ignite wood + if ( + random.random() < 0.3 + ): # 30% chance to spread + self.ignite_particle(neighbor) + elif neighbor.flamability > 0: + if ( + random.random() < 0.1 + ): # 10% chance for other materials + self.ignite_particle(neighbor) + + def handle_special_particles( + self, particle, x, y + ): # this is where we handle special particles. + """Handle special particle behaviors""" + if particle.particle_type in ["fire", "flame", "smoke"]: + if random.random() < 0.6: # % chance + self.particles[x][y] = None + self.active_particles.discard((x, y)) + + if particle.particle_type in ["fire", "flame", "lava"]: + # Create smoke above with proper physics + if random.random() < 0.65 and y > 0: # % chance for smoke + properties = self.particle_properties["smoke"] + new_smoke = Particle.from_type( + (x, y - 1), "smoke", properties + ) + if self.particles[x][y - 1] is None: + self.particles[x][y - 1] = new_smoke + self.active_particles.add((x, y - 1)) + + else: + # Handle collision with water + if particle.particle_type == "water": + self.particles[x][y - 1] = None + self.active_particles.discard((x, y - 1)) + self.particles[x][y] = None + self.active_particles.discard((x, y)) + self.particles[x][y] = Particle.from_type( + (x, y), "water", self.particle_properties["water"] + ) + self.active_particles.add((x, y)) + + def handle_temperature( + self, dt + ): # this is where we handle the temperature. + """Handle temperature changes and state transitions""" + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if not particle: + continue + if particle.temperature > 1700: + # Transition to gas + particle.is_gas = True + particle.temperature = 1700 + particle.velocity = [ + random.uniform(-1, 1), + random.uniform(-1, 1), + ] + particle.temperature < 1400 + particle.is_gas = False + + # Temperature spread to neighbors + for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + nx, ny = x + dx, y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + neighbor = self.particles[nx][ny] + if ( + neighbor + and hasattr(neighbor, "temperature") + and neighbor.temperature is not None + ): + temp_diff = particle.temperature - neighbor.temperature + heat_transfer = temp_diff * 0.1 * dt + particle.temperature -= heat_transfer + neighbor.temperature += heat_transfer + + def burning(self): # this is where we handle the burning. + """Handle burning of particles.""" + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if particle and hasattr(particle, "burning") and particle.burning: + particle.temperature += 10 + if particle.temperature > 1000: + self.particles[x][y] = None + self.active_particles.remove((x, y)) + self.spatial_grid.pop((x, y), None) + + def create_particle(self, x, y): # this is where we create the particle. + """Create a new particle with full property support""" + particle_type = self.current_particle_type.lower() + # Check if the particle is within the grid boundaries + if particle_type in self.particle_properties: + grid_x = x // self.particle_size + grid_y = y // self.particle_size + + if 0 <= grid_x < self.width and 0 <= grid_y < self.height: + properties = self.particle_properties[particle_type] + position = (grid_x, grid_y) + new_particle = Particle( + position=position, + velocity=[0, 0], + mass=properties.get("mass", 1.0), + particle_type=particle_type, + properties=properties, + ) + # Add to the grid + if 0 <= grid_x < len(self.particles) and 0 <= grid_y < len( + self.particles[0] + ): + self.particles[grid_x][grid_y] = new_particle + self.active_particles.add((grid_x, grid_y)) + self.particle_count += 1 + + def create_particle_circle( + self, center_x, center_y + ): # this is where we create the particle circle. + brush_size = int(self.brush_size) + for dx in range(-brush_size, brush_size + 1): + for dy in range(-brush_size, brush_size + 1): + if ( + dx * dx + dy * dy <= brush_size * brush_size + ): # Circle check + self.create_particle( + center_x + dx * self.particle_size, + center_y + dy * self.particle_size, + ) + + def get_particle_state( + self, x, y + ): # this is where we get the particle state. + """Get the state of a particle at a given position""" + particle = self.particles[x][y] + if particle: + return particle.particle_type + return None + + def apply_gravity(self, dt): # this is where we apply gravity. + """Handle only gravity and basic particle movement""" + self.spatial_grid.clear() + + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if not particle or particle.particle_type == "wall": + continue + + # Apply gravity + new_y = y + 1 + new_x = x + + # Check boundaries + if not (0 <= new_x < self.width and 0 <= new_y < self.height): + continue + + # Handle granular materials (sand, dirt) + if particle.particle_type in ["sand", "dirt", "snow", "ice"]: + if self.particles[x][new_y] is None: + new_x, new_y = x, y + 1 + else: + # Try diagonal movement with randomization + diagonal_dirs = [(-1, 1), (1, 1)] + random.shuffle(diagonal_dirs) + for dx, dy in diagonal_dirs: + test_x = x + dx + test_y = y + dy + if ( + 0 <= test_x < self.width + and 0 <= test_y < self.height + and self.particles[test_x][test_y] is None + ): + if ( + random.random() < 0.8 + ): # 80% chance to move diagonally + new_x = test_x + new_y = test_y + break + + # Handle liquid movement (water, lava) + elif particle.liquid: + if self.particles[x][new_y] is None: + new_x = x + new_y = y + 1 + else: + spread_directions = [(-1, 0), (1, 0)] + random.shuffle(spread_directions) + for dx, _ in spread_directions: + test_x = x + dx + if ( + 0 <= test_x < self.width + and self.particles[test_x][y] is None + ): + new_x = test_x + new_y = y + break + + # Move particle if destination is empty + if self.particles[new_x][new_y] is None: + self.particles[x][y] = None + self.particles[new_x][new_y] = particle + self.active_particles.add((new_x, new_y)) + self.active_particles.discard((x, y)) + particle.position = (new_x, new_y) + + def apply_physics( + self, dt, engine_settings + ): # this is where we apply physics. + """Handle all physics effects""" + new_active_particles = set() + + for x, y in list(self.active_particles): + particle = self.particles[x][y] + if not particle: + continue + + # Handle boundaries based on settings + if engine_settings["outerwall"]: + if ( + x <= 0 + or x >= self.width - 1 + or y <= 0 + or y >= self.height - 1 + ): + # Create wall particle at boundary if none exists + if self.particles[x][y] is None: + properties = self.particle_properties["wall"] + wall = Particle.from_type((x, y), "wall", properties) + self.particles[x][y] = wall + new_active_particles.add((x, y)) + self.particle_count += 1 # Track new wall particle + continue + else: + # Delete particles that go out of bounds + if ( + x <= 0 + or x >= self.width - 1 + or y <= 0 + or y >= self.height - 1 + ): + if self.particles[x][y] is not None: + self.particles[x][y] = None + self.active_particles.discard((x, y)) + self.particle_count -= 1 + continue + + # Skip wall physics - walls are immutable + if particle.particle_type == "wall": + new_active_particles.add((x, y)) + continue + + # Handle dissipating particles + if particle.particle_type in ["fire", "flame"]: + # Clear current position first + self.particles[x][y] = None + self.active_particles.discard((x, y)) + + # Handle fire movement + dx = random.uniform(-0.5, 0.5) + dy = random.uniform(-1.5, -0.5) # Upward drift + new_x = int(x + dx) + new_y = int(y + dy) + + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[new_x][new_y] is None: + self.particles[new_x][new_y] = particle + new_active_particles.add((new_x, new_y)) + + # Generate smoke above the new fire position + if random.random() < 0.25 and new_y > 0: + properties = self.particle_properties["smoke"] + new_smoke = Particle( + position=(new_x, new_y - 1), + velocity=[random.uniform(-0.5, 0.5), -1], + mass=properties.get("mass", 0.1), + particle_type="smoke", + properties=properties, + ) + if self.particles[new_x][new_y - 1] is None: + self.particles[new_x][new_y - 1] = new_smoke + new_active_particles.add((new_x, new_y - 1)) + + # Dissipation chance + if random.random() < 0.02: + continue + + continue + + # Air handling - particles can pass through should implement proper air instead of a particle + if particle.particle_type == "air": + continue + + # Handle phase transitions + self.handle_phase_transitions(particle, x, y) + + # Calculate forces + fx, fy = self.calculate_forces(particle, x, y) + + # handle gas particles + if particle.is_gas: + # Gas-specific movement + dx = random.uniform(2, -1) + dy = random.uniform(-2, 0) # Bias upward + new_x = int(x + dx) + new_y = int(y + dy) + + self.particles[x][y] = None + self.active_particles.discard((x, y)) + + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[new_x][new_y] is None: + self.particles[x][y] = None + self.particles[new_x][new_y] = particle + new_active_particles.add((new_x, new_y)) + self.active_particles.discard((x, y)) + continue + else: + # Regular particle physics + mass = max(particle.mass, 0.001) + particle.velocity[0] += (fx / mass) * dt + particle.velocity[1] += (fy / mass) * dt + + if particle.liquid: + # Enhanced liquid spreading + spread_chance = 0.5 + if random.random() < spread_chance: + dx = random.choice([-1, 1]) + if ( + 0 <= x + dx < self.width + and self.particles[x + dx][y] is None + ): + new_x = x + dx + new_y = y + self.particles[x][y] = None + self.particles[new_x][new_y] = particle + new_active_particles.add((new_x, new_y)) + continue + + # Update position for non-liquid particles + new_x = int(x + particle.velocity[0] * dt) + new_y = int(y + particle.velocity[1] * dt) + + if 0 <= new_x < self.width and 0 <= new_y < self.height: + if self.particles[new_x][new_y] is None: + self.particles[x][y] = None + self.particles[new_x][new_y] = particle + + else: + new_active_particles.add((x, y)) + + self.active_particles = new_active_particles + + def clear_particles_circle( + self, center_x, center_y + ): # this is for the brush tool + """Clear particles in a circle around the given point based on brush size""" + brush_size = int(self.brush_size) + particles_cleared = 0 # Track how many particles we clear + + for dx in range(-brush_size, brush_size + 1): + for dy in range(-brush_size, brush_size + 1): + if ( + dx * dx + dy * dy <= brush_size * brush_size + ): # Circle check + grid_x = ( + center_x + dx * self.particle_size + ) // self.particle_size + grid_y = ( + center_y + dy * self.particle_size + ) // self.particle_size + + if 0 <= grid_x < self.width and 0 <= grid_y < self.height: + if self.particles[grid_x][grid_y]: + self.particles[grid_x][grid_y] = None + self.active_particles.discard((grid_x, grid_y)) + self.remove_from_spatial_grid(grid_x, grid_y) + particles_cleared += 1 + + self.particle_count = max( + 0, self.particle_count - particles_cleared + ) # Update count, ensure it doesn't go negative + + def mix_liquids(self, liquid1, liquid2): # this is for the mix tool + """Handle liquid mixing interactions""" + if liquid1.temperature != liquid2.temperature: + avg_temp = (liquid1.temperature + liquid2.temperature) / 2 + liquid1.temperature = avg_temp + liquid2.temperature = avg_temp + liquid1.density = self.calculate_density(liquid1.temperature) + liquid2.density = self.calculate_density(liquid2.temperature) + liquid1.viscosity = self.calculate_viscosity(liquid1.temperature) + liquid2.viscosity = self.calculate_viscosity(liquid2.temperature) + liquid1.color = self.calculate_color(liquid1.temperature) + liquid2.color = self.calculate_color(liquid2.temperature) + + def _wake_neighbors(self, x, y): + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + nx, ny = x + dx, y + dy + key = (nx, ny) + if key in self.dormant_particles: + self.dormant_particles.discard(key) + self.particle_movement_counter[key] = 0 + + def track_tps(self): + """Track Ticks Per Second for simulation performance monitoring""" + if not hasattr(self, "_tps_counter"): + self._tps_counter = 0 + self._tps_timer = time.time() + self._current_tps = 0 + + self._tps_counter += 1 + current_time = time.time() + elapsed = current_time - self._tps_timer + + # Update TPS count every second + if elapsed >= 1.0: + self._current_tps = self._tps_counter / elapsed + self._tps_counter = 0 + self._tps_timer = current_time + + return self._current_tps + + def simulate_step(self, dt, engine_settings): + """Run a single step of the simulation""" + + """ + active_list = list(self.active_particles) + batch_size = 1000 + + for i in range(0, len(active_list), batch_size): + batch = active_list[i:i + batch_size] + self._process_particle_batch(batch, dt) + """ + # Update spatial grid only when needed + if len(self.active_particles) > 100: + self.update_spatial_grid() + + # Update particle positions and physics + self.apply_gravity(dt) + self.apply_physics(dt, engine_settings) + + # Handle state changes and interactions + self.handle_temperature(dt) + self.handle_particle_interactions(dt) + self.burning() + self.spread_fire() diff --git a/pyproject.toml b/python/pyproject.toml similarity index 63% rename from pyproject.toml rename to python/pyproject.toml index a391f2c..a0b3686 100644 --- a/pyproject.toml +++ b/python/pyproject.toml @@ -12,8 +12,23 @@ description = "Falling Sand Simulation in Python" readme = "README.md" requires-python = ">=3.8" dependencies = [ - "numpy>=1.21.0", - "pygame>=2.1.0" + "numpy>=1.21.0,<3.0.0", + "pygame>=2.1.0", + "numba>=0.57.0" +] + +[project.optional-dependencies.gpu] +# GPU acceleration (optional) +taichi = ">=1.6.0" +cupy = ">=12.0.0" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] [project.optional-dependencies] @@ -57,3 +72,9 @@ strict_optional = true max-line-length = 79 ignored-modules = ["pygame"] disable = ["E1101"] + +[tool.setuptools] +package-dir = {"" = "python"} + +[tool.setuptools.packages.find] +where = ["python"] diff --git a/requirements-dev.txt b/python/requirements-dev.txt similarity index 93% rename from requirements-dev.txt rename to python/requirements-dev.txt index 357b045..3ff2c22 100644 --- a/requirements-dev.txt +++ b/python/requirements-dev.txt @@ -5,3 +5,4 @@ pytest-cov>=4.0 black>=22.0 isort>=5.0 mypy>=0.9 +psutil \ No newline at end of file diff --git a/python/requirements.txt b/python/requirements.txt new file mode 100644 index 0000000..167aa11 --- /dev/null +++ b/python/requirements.txt @@ -0,0 +1,4 @@ +numpy +pygame-ce +numba +# cupy # Uncomment only if you have NVIDIA GPU and want to use the pre-release \ No newline at end of file diff --git a/sandpypi.py b/python/sandpypi.py similarity index 64% rename from sandpypi.py rename to python/sandpypi.py index 39184cd..dfe37e3 100644 --- a/sandpypi.py +++ b/python/sandpypi.py @@ -2,7 +2,7 @@ #File Name: sandpypi.py # Sandpypi by Stanton. # Project name is a placeholder. -# This has been a multimonth or year project i have time blindness sorta. +# This has been a multi year project i have time blindness. # This is my most functional system for falling sand in python yet i took # some things i learned in JS. # This needs further optimizations to core performance sections. @@ -18,17 +18,46 @@ The main loop runs at a target frame rate of 60 FPS displayed in the debug overlay. """ -# Import Require files for the Engine. -import pygame +import sys +from pathlib import Path -from src.config.settings import cProfile, engine_settings, pstats, time +sys.path.insert(0, str(Path(__file__).resolve().parent / "python")) + +# Import Require files for the Engine. + +from typing import TypedDict + +import pygame +import time + +from src.config.settings import ( + cProfile, + engine_settings, + pstats, +) from src.physics.sim import Simulation from src.rendering.rendering import Rendering +class InputAction(TypedDict, total=False): + mouse_down_left: bool + mouse_down_right: bool + mouse_down_middle: bool + settings_visible: bool + over_button: bool + zoom_active: bool + zoom_locked: bool + zoom_pos: tuple[int, int] + running: bool + + def handle_input( - event, sim, rendering, settings_visible, zoom_active, zoom_locked, zoom_pos -): + event: pygame.event.Event, + sim: Simulation, + rendering: Rendering, + settings_visible: bool, + zoom_active: bool, # zoom_locked, zoom_pos +) -> InputAction | None: """Handle all input events""" if event.type == pygame.MOUSEBUTTONDOWN: return handle_mouse_down( @@ -37,11 +66,18 @@ def handle_input( if event.type == pygame.MOUSEBUTTONUP: return handle_mouse_up(event) if event.type == pygame.KEYDOWN: + # Cast to event with key attribute safely or assume it has checks return handle_key_press(event, rendering, sim) return None -def handle_mouse_down(event, sim, rendering, settings_visible, zoom_active): +def handle_mouse_down( + event: pygame.event.Event, + sim: Simulation, + rendering: Rendering, + settings_visible: bool, + zoom_active: bool, +) -> InputAction: """Handle mouse button down events""" mouse_pos = pygame.mouse.get_pos() in_settings_area = False @@ -50,6 +86,7 @@ def handle_mouse_down(event, sim, rendering, settings_visible, zoom_active): settings_rect = pygame.Rect(rendering.width - 320, 100, 300, 400) in_settings_area = settings_rect.collidepoint(mouse_pos) + # event.button is int if event.button == 4: # Mouse wheel up sim.brush_size = min(sim.brush_size + 1, sim.max_brush_size) elif event.button == 5: # Mouse wheel down @@ -71,51 +108,97 @@ def handle_mouse_down(event, sim, rendering, settings_visible, zoom_active): def handle_left_click( - mouse_pos, sim, rendering, settings_visible, in_settings_area, zoom_active -): + mouse_pos: tuple[int, int], + sim: Simulation, + rendering: Rendering, + settings_visible: bool, + in_settings_area: bool, + zoom_active: bool, +) -> InputAction: """Handle left click interactions""" - result = { + result: InputAction = { "mouse_down_left": False, "settings_visible": settings_visible, "over_button": False, } - if rendering.settings_button.collidepoint(mouse_pos): - result["settings_visible"] = not settings_visible - result["over_button"] = True - return result + # Sidebar UI Check + if mouse_pos[0] < rendering.sidebar_width: + if handle_ui_click(mouse_pos, sim, rendering): + result["over_button"] = True + # Check if settings was toggled + if rendering.settings_button.collidepoint(mouse_pos): + result["settings_visible"] = not settings_visible + return result if zoom_active: - result["zoom_locked"] = not result.get("zoom_locked", False) + current_locked = bool(result.get("zoom_locked", False)) + result["zoom_locked"] = not current_locked if result["zoom_locked"]: result["zoom_pos"] = mouse_pos return result if settings_visible and in_settings_area: - handle_settings_click(mouse_pos) + handle_settings_click(mouse_pos, rendering, sim) result["over_button"] = True return result if not in_settings_area: - if handle_ui_click(mouse_pos, sim, rendering): - result["over_button"] = True - else: - result["mouse_down_left"] = True + result["mouse_down_left"] = True return result -def handle_settings_click(mouse_pos): +def handle_settings_click(mouse_pos: tuple[int, int], rendering: Rendering, sim: Simulation) -> None: """Handle clicks in settings menu""" + # Settings menu is now drawn at rendering.width - 320, 100 settings_menu_y = 100 + relative_y = mouse_pos[1] - settings_menu_y setting_index = relative_y // 30 + if 0 <= setting_index < len(engine_settings): setting_name = list(engine_settings.keys())[setting_index] engine_settings[setting_name] = not engine_settings[setting_name] + + # Handle side effects + if setting_name == "enable_acceleration": + from src.acceleration.simulation_bridge import AcceleratedSimulation + if engine_settings["enable_acceleration"]: + if not sim.acceleration_wrapper: + sim.acceleration_wrapper = AcceleratedSimulation(sim) + else: + sim.acceleration_wrapper.enable_acceleration = True + elif sim.acceleration_wrapper: + sim.acceleration_wrapper.enable_acceleration = False + if setting_name == "fast_sim": + sim.fast_mode = bool(engine_settings["fast_sim"]) + if sim.fast_mode: + sim._init_fast_storage() + # Clear state when switching modes + sim.particles = [ + [None for _ in range(sim.height)] for _ in range(sim.width) + ] + if sim.fast_type_id is not None: + sim.fast_type_id.fill(0) + if sim.fast_temp is not None: + sim.fast_temp.fill(sim.ambient_temperature) + if sim.fast_burn_time is not None: + sim.fast_burn_time.fill(0) + if sim.fast_burning is not None: + sim.fast_burning.fill(0) + if sim.fast_spark_time is not None: + sim.fast_spark_time.fill(0) + if sim.fast_lifetime is not None: + sim.fast_lifetime.fill(0) + sim.active_particles.clear() + sim.occupied_cells.clear() + sim.particle_count = 0 -def handle_ui_click(mouse_pos, sim, rendering): +def handle_ui_click( + mouse_pos: tuple[int, int], sim: Simulation, rendering: Rendering +) -> bool: """Handle clicks on UI elements""" for category, button in rendering.category_buttons.items(): if button.collidepoint(mouse_pos): @@ -134,7 +217,7 @@ def handle_ui_click(mouse_pos, sim, rendering): return False -def handle_mouse_up(event): +def handle_mouse_up(event: pygame.event.Event) -> InputAction: """Handle mouse button up events""" if event.button == 1: return {"mouse_down_left": False} @@ -145,7 +228,9 @@ def handle_mouse_up(event): return {} -def handle_key_press(event, rendering, sim): +def handle_key_press( + event: pygame.event.Event, rendering: Rendering, sim: Simulation +) -> InputAction: """Handle keyboard press events""" if event.key == pygame.K_ESCAPE: print("Escape button pressed") @@ -165,17 +250,26 @@ def handle_key_press(event, rendering, sim): return {} -def main(): +def main() -> None: """Main function to run the simulation""" pygame.init() clock = pygame.time.Clock() width = 1024 height = 768 + particle_size = 3 screen = pygame.display.set_mode( (width, height), pygame.HWSURFACE | pygame.DOUBLEBUF ) - sim = Simulation(width, height) + sim = Simulation(width // particle_size, height // particle_size, particle_size=particle_size) + + # Initialize acceleration if enabled by default + if engine_settings.get("enable_acceleration"): + from src.acceleration.simulation_bridge import AcceleratedSimulation + sim.acceleration_wrapper = AcceleratedSimulation(sim) + rendering = Rendering(width, height) + # num_cores = multiprocessing.cpu_count() + # pool = multiprocessing.Pool(processes=num_cores) # State variables mouse_down_left = False @@ -184,17 +278,16 @@ def main(): over_button = False zoom_active = False zoom_locked = False - zoom_pos = None + # zoom_pos = None settings_visible = False running = True - last_particle_time = 0 + last_particle_time = 0.0 while running: # Clear screen at start of frame - screen.fill((0, 0, 0)) - + screen.fill(color=(0, 0, 0)) fps = clock.get_fps() - dt = clock.tick(60) / 1000 + dt = clock.tick(120) / 1000 keys = pygame.key.get_pressed() mouse_pos = pygame.mouse.get_pos() zoom_active = keys[pygame.K_z] @@ -232,19 +325,12 @@ def main(): elif zoom_active: zoom_locked = not zoom_locked if zoom_locked: - zoom_pos = mouse_pos + # zoom_pos = mouse_pos + pass # Handle settings menu interactions elif settings_visible and in_settings_area: - relative_y = mouse_pos[1] - 100 # settings_menu_y - setting_index = relative_y // 30 - if 0 <= setting_index < len(engine_settings): - setting_name = list(engine_settings.keys())[ - setting_index - ] - engine_settings[ - setting_name - ] = not engine_settings[setting_name] + handle_settings_click(mouse_pos, rendering, sim) over_button = True elif not in_settings_area: @@ -305,11 +391,23 @@ def main(): elif event.key == pygame.K_z: zoom_active = True zoom_locked = False - zoom_pos = pygame.mouse.get_pos() + # zoom_pos = pygame.mouse.get_pos() # Update simulation if not paused if not engine_settings["pause_sim"]: - sim.simulate_step(dt, engine_settings) + sim.simulate_step(dt) + # regions = sim.grid_system.split_into_processing_regions(num_cores) + + # # Process each region's physics + # for region_data in regions.values(): + # for particles in region_data.values(): + # for x, y in particles: + # # Process physics for particles in this region + # sim.handle_temperature(dt) + # sim.handle_particle_interactions() + + # # Update spatial grid after processing + # sim.grid_system.update_spatial_grid() # Handle continuous mouse input if mouse_down_left and not over_button: @@ -319,7 +417,7 @@ def main(): # Limit particle creation to every 16ms (approximately 60 FPS) if current_time - last_particle_time >= 0.016: sim.create_particle_circle(x, y) - last_particle_time = current_time + last_particle_time = current_time # just works. if mouse_down_right: x, y = mouse_pos @@ -331,25 +429,38 @@ def main(): # Handle zoom window if zoom_active or zoom_locked: - current_zoom_pos = zoom_pos if zoom_locked else mouse_pos + """current_zoom_pos = zoom_pos if zoom_locked else mouse_pos zoom_surface = rendering.draw_zoom_window( sim.particles, sim.particle_size, rendering.particle_colors, current_zoom_pos, ) - zoom_x = 80 if current_zoom_pos[0] > width / 2 else width - 110 - zoom_y = 80 if current_zoom_pos[1] > height / 2 else height - 110 + zoom_x: int = ( + 80 if current_zoom_pos[0] > width / 2 else width - 110 + ) + zoom_y: int = ( + 80 if current_zoom_pos[1] > height / 2 else height - 110 + ) screen.blit(zoom_surface, (zoom_x, zoom_y)) - + """ + pass # Draw everything in correct order - rendering.draw_particles( - sim.particles, - sim.active_particles, - sim.particle_size, - rendering.particle_colors, - ) - rendering.draw_buttons() + if getattr(sim, "fast_mode", False) and getattr(sim, "fast_type_id", None) is not None: + rendering.draw_particles_fast( + sim.fast_type_id, + sim.particle_size, + sim.fast_color_lut, + sim.fast_spark_time, + ) + else: + rendering.draw_particles( + sim.particles, + sim.occupied_cells, + sim.particle_size, + rendering.particle_colors, + ) + rendering.draw_buttons(mouse_pos, sim.current_particle_type) rendering.draw_brush_size_slider(sim.brush_size) rendering.render_brush_cursor( mouse_pos[0], mouse_pos[1], sim.brush_size * sim.particle_size diff --git a/src/config/__init__.py b/python/scripts/OLD-SCRIPT-TO-SET-UP-PYTHON-DEV similarity index 100% rename from src/config/__init__.py rename to python/scripts/OLD-SCRIPT-TO-SET-UP-PYTHON-DEV diff --git a/scripts/lint.py b/python/scripts/lint.py similarity index 100% rename from scripts/lint.py rename to python/scripts/lint.py diff --git a/scripts/setup_dev.py b/python/scripts/setup_dev.py similarity index 100% rename from scripts/setup_dev.py rename to python/scripts/setup_dev.py diff --git a/setup.py b/python/setup.py similarity index 58% rename from setup.py rename to python/setup.py index 2ebf57e..fd4c095 100644 --- a/setup.py +++ b/python/setup.py @@ -4,5 +4,6 @@ from setuptools import find_packages, setup # type: ignore setup( name="sandpypi", - packages=find_packages(), + package_dir={"": "python"}, + packages=find_packages(where="python"), ) diff --git a/src/__init__.py b/python/src/__init__.py similarity index 100% rename from src/__init__.py rename to python/src/__init__.py diff --git a/python/src/acceleration/__init__.py b/python/src/acceleration/__init__.py new file mode 100644 index 0000000..6cda3b9 --- /dev/null +++ b/python/src/acceleration/__init__.py @@ -0,0 +1,13 @@ +""" +High-Performance Physics Acceleration Module +========================================== + +Provides hybrid GPU/CPU acceleration for particle physics with automatic detection: +- GPU acceleration via Taichi or CuPy when available +- Numba JIT CPU acceleration as fallback +- Seamless integration with existing simulation code +""" + +from .hybrid_physics import HybridPhysicsEngine, AccelerationMode + +__all__ = ["HybridPhysicsEngine", "AccelerationMode"] diff --git a/python/src/acceleration/hybrid_physics.py b/python/src/acceleration/hybrid_physics.py new file mode 100644 index 0000000..dc64dcd --- /dev/null +++ b/python/src/acceleration/hybrid_physics.py @@ -0,0 +1,498 @@ +from __future__ import annotations +import logging +import time +from typing import Any, TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + # import taichi as ti + # import cupy as cp + from ..physics.sim import Simulation +else: + # Dummy objects for runtime if libraries are missing + ti: Any = None + cp: Any = None + +numba_available: bool = False +taichi_available: bool = False +cupy_available: bool = False + +# Dummy decorators for type checking and fallback +def _dummy_jit(*args: Any, **kwargs: Any) -> Any: + if len(args) == 1 and callable(args[0]): + return args[0] + return lambda f: f + +# Try importing acceleration libraries +try: + import numba + from numba import jit, prange + numba_available = True +except (ImportError, ModuleNotFoundError): + numba_available = False + jit = _dummy_jit + prange = range + logging.warning("Numba not available - falling back to pure Python") + +try: + import taichi as ti_pkg + ti = ti_pkg + taichi_available = True +except (ImportError, ModuleNotFoundError): + taichi_available = False + +try: + import cupy as cp_pkg + cp = cp_pkg + cupy_available = True +except (ImportError, ModuleNotFoundError): + cupy_available = False + + +class AccelerationMode: + """Enumeration of available acceleration modes""" + PURE_PYTHON = "pure_python" + NUMBA_CPU = "numba_cpu" + TAICHI_GPU = "taichi_gpu" + CUPY_GPU = "cupy_gpu" + + +class HybridPhysicsEngine: + """ + Hybrid physics engine with automatic GPU/CPU detection and optimization + """ + + width: int + height: int + particle_size: int + acceleration_mode: str + apply_gravity_gpu: Any + handle_collisions_gpu: Any + apply_physics_numba: Any + handle_particle_interactions_numba: Any + ti_positions: Any + ti_velocities: Any + ti_particle_types: Any + ti_active_mask: Any + device: Any + stream: Any + + # Cached detection results + _cached_mode: str | None = None + _cached_benchmarks: dict[str, float] | None = None + + is_gas_cpu: np.ndarray | None = None + ti_positions: Any = None + ti_velocities: Any = None + ti_active_mask: Any = None + ti_particle_types: Any = None + ti_is_gas: Any = None + ti_collision_map: Any = None + + # Kernel functions + apply_gravity_gpu: Any = None + update_collision_map: Any = None + + def __init__(self, width: int, height: int, original_sim: Simulation, particle_size: int = 3) -> None: + self.width = width + self.height = height + self.particle_size = particle_size + self.sim = original_sim + + # Initialize attributes to None or defaults + self.apply_gravity_gpu = None + self.handle_collisions_gpu = None + self.apply_physics_numba = None + self.handle_particle_interactions_numba = None + self.ti_positions = None + self.ti_velocities = None + self.ti_particle_types = None + self.ti_active_mask = None + self.device = None + self.stream = None + + # Performance tracking + self.benchmark_results: dict[str, float] = {} + + # Detect and initialize best available acceleration + if HybridPhysicsEngine._cached_mode is None: + self.acceleration_mode = self._detect_best_acceleration() + HybridPhysicsEngine._cached_mode = self.acceleration_mode + HybridPhysicsEngine._cached_benchmarks = self.benchmark_results + else: + self.acceleration_mode = HybridPhysicsEngine._cached_mode + self.benchmark_results = HybridPhysicsEngine._cached_benchmarks or {} + + self._initialize_acceleration() + + logging.info("Physics acceleration mode: %s", self.acceleration_mode) + + def _detect_best_acceleration(self) -> str: + """Detect the best available acceleration method""" + + # Check for GPU acceleration first + if taichi_available: + try: + ti.init(arch=ti.gpu, device_memory_GB=1) + if self._benchmark_gpu_taichi(): + return AccelerationMode.TAICHI_GPU + ti.reset() + except Exception as e: + logging.debug("Taichi GPU failed: %s", e) + + if cupy_available: + try: + # Test CUDA availability + cp.cuda.runtime.getDeviceCount() + if self._benchmark_gpu_cupy(): + return AccelerationMode.CUPY_GPU + except Exception as e: + logging.debug("CuPy CUDA failed: %s", e) + + # Fallback to CPU acceleration + if numba_available: + return AccelerationMode.NUMBA_CPU + + logging.warning("No acceleration available - using pure Python") + return AccelerationMode.PURE_PYTHON + + def _benchmark_gpu_taichi(self) -> bool: + """Benchmark Taichi GPU performance""" + try: + # Simple test kernel + @ti.kernel + def test_kernel(n: int) -> float: + temp = 0.0 + for _ in range(n): + temp += 1.0 + return temp + + start = time.perf_counter() + test_kernel(100000) + ti.sync() + end = time.perf_counter() + + gpu_time = end - start + self.benchmark_results["taichi_gpu"] = gpu_time + + # If it takes > 0.5s for a simple kernel, GPU might be too slow/emulated + return gpu_time < 0.5 + except Exception: + return False + + def _benchmark_gpu_cupy(self) -> bool: + """Benchmark CuPy GPU performance""" + try: + start = time.perf_counter() + a = cp.random.random((1000, 1000), dtype=cp.float32) + cp.sum(a) + cp.cuda.Stream.null.synchronize() + end = time.perf_counter() + + gpu_time = end - start + self.benchmark_results["cupy_gpu"] = gpu_time + return gpu_time < 0.5 + except Exception: + return False + + def _initialize_acceleration(self): + """Initialize selected acceleration method""" + if self.acceleration_mode == AccelerationMode.TAICHI_GPU: + self._init_taichi() + elif self.acceleration_mode == AccelerationMode.CUPY_GPU: + self._init_cupy() + elif self.acceleration_mode == AccelerationMode.NUMBA_CPU: + self._init_numba() + + def _init_taichi(self): + """Initialize Taichi fields and kernels""" + if not taichi_available: return + + ti.init(arch=ti.gpu) + + # Allocate GPU fields (up to 1M particles) + max_particles = 1000000 + # Data fields for GPU + self.ti_positions = ti.Vector.field(2, dtype=ti.f32, shape=max_particles) + self.ti_velocities = ti.Vector.field(2, dtype=ti.f32, shape=max_particles) + self.ti_particle_types = ti.field(dtype=ti.i32, shape=max_particles) + self.ti_active_mask = ti.field(dtype=ti.i32, shape=max_particles) + self.ti_is_gas = ti.field(dtype=ti.i32, shape=max_particles) + + # Grid fields for collisions + self.ti_collision_map = ti.field(dtype=ti.i32, shape=(self.width, self.height)) + + self._create_taichi_kernels() + + def _init_cupy(self): + """Initialize CuPy device and stream""" + if not cupy_available: return + + # CuPy uses numpy-like syntax but runs on GPU + self.device = cp.cuda.Device(0) + self.stream = cp.cuda.Stream() + + def _init_numba(self): + """Initialize Numba CPU acceleration""" + # Pre-compile critical functions + self._compile_numba_functions() + + def _create_taichi_kernels(self): + """Create Taichi GPU compute kernels""" + + @ti.kernel + def apply_gravity_gpu(num_particles: int, dt: float): + for i in range(num_particles): + if self.ti_active_mask[i]: + # Gravity + g = 30.0 + if self.ti_is_gas[i]: + g = -15.0 # Buoyancy + + self.ti_velocities[i][1] += g * dt + + # Simple drag + self.ti_velocities[i] *= 0.99 + + # Update position + new_pos = self.ti_positions[i] + self.ti_velocities[i] * dt + + # Boundary Check + if (new_pos[0] >= 0 and new_pos[0] < self.width and + new_pos[1] >= 0 and new_pos[1] < self.height): + + # Collision Check + ix, iy = int(new_pos[0]), int(new_pos[1]) + if self.ti_collision_map[ix, iy] == 0: + self.ti_positions[i] = new_pos + else: + # Hit something + self.ti_velocities[i] = self.ti_velocities[i] * 0.0 + else: + # Ground collision or out of bounds + if new_pos[1] >= self.height: + self.ti_positions[i][1] = self.height - 1 + self.ti_velocities[i] = self.ti_velocities[i] * 0.0 + else: + self.ti_active_mask[i] = 0 + + @ti.kernel + def update_collision_map(num_particles: int): + # No-op since we use from_numpy for the map + pass + + self.apply_gravity_gpu = apply_gravity_gpu + self.update_collision_map = update_collision_map + + def _compile_numba_functions(self): + """Compile Numba-accelerated functions""" + + @jit(nopython=True, cache=True) + def apply_physics_numba(positions, velocities, particle_types, active_mask, is_gas, width, height, dt, collision_map): + n = len(positions) + for i in prange(n): + if active_mask[i]: + # Gravity matching sim.py + grav = -30.0 if is_gas[i] else 30.0 + velocities[i, 1] += grav * dt + + # Move (float positions) + new_x = positions[i, 0] + velocities[i, 0] * dt + new_y = positions[i, 1] + velocities[i, 1] * dt + + # Bounds check + if 0 <= new_x < width and 0 <= new_y < height: + # Collision check + ix, iy = int(new_x), int(new_y) + if collision_map[ix, iy] == 0: + positions[i, 0] = new_x + positions[i, 1] = new_y + else: + velocities[i, 0] = 0 + velocities[i, 1] = 0 + else: + active_mask[i] = False + + @jit(nopython=True) + def handle_particle_interactions_numba(positions, particle_types, active_mask, width, height): + # Complex interactions implemented in Numba + pass + + self.apply_physics_numba = apply_physics_numba + self.handle_particle_interactions_numba = handle_particle_interactions_numba + + def update_particles(self, particles_data: dict[str, np.ndarray], dt: float) -> dict[str, np.ndarray]: + """ + Main physics update function - automatically uses best acceleration + + Args: + particles_data: Dict containing particle arrays (positions, velocities, etc.) + dt: Time delta + + Returns: + Updated particle data + """ + + if self.acceleration_mode == AccelerationMode.TAICHI_GPU: + return self._update_particles_taichi(particles_data, dt) + elif self.acceleration_mode == AccelerationMode.CUPY_GPU: + return self._update_particles_cupy(particles_data, dt) + elif self.acceleration_mode == AccelerationMode.NUMBA_CPU: + return self._update_particles_numba(particles_data, dt) + else: + return self._update_particles_python(particles_data, dt) + + def _update_particles_taichi(self, particles_data: dict[str, Any], dt: float) -> dict[str, Any]: + """GPU-accelerated update using Taichi""" + + # Copy data to GPU + positions = particles_data["positions"] + velocities = particles_data["velocities"] + active_mask = particles_data["active_mask"] + is_gas = particles_data["is_gas"] + num_particles = len(positions) + + # Transfer to Taichi fields + self.ti_positions.from_numpy(positions) + self.ti_velocities.from_numpy(velocities) + self.ti_active_mask.from_numpy(active_mask.astype(np.int32)) + self.ti_is_gas.from_numpy(is_gas.astype(np.int32)) + + # Transfer collision map + if "collision_map" in particles_data: + self.ti_collision_map.from_numpy(particles_data["collision_map"]) + + # Run GPU kernels + self.update_collision_map(num_particles) + self.apply_gravity_gpu(num_particles, dt) + + # Copy results back + particles_data["positions"] = self.ti_positions.to_numpy()[:num_particles] + particles_data["velocities"] = self.ti_velocities.to_numpy()[:num_particles] + particles_data["active_mask"] = self.ti_active_mask.to_numpy()[:num_particles].astype(bool) + + return particles_data + + def _update_particles_cupy(self, particles_data: dict[str, np.ndarray], dt: float) -> dict[str, np.ndarray]: + """GPU-accelerated update using CuPy""" + + with self.device: + # Transfer to GPU + positions_gpu = cp.asarray(particles_data["positions"]) + velocities_gpu = cp.asarray(particles_data["velocities"]) + active_mask_gpu = cp.asarray(particles_data["active_mask"]) + + # Apply physics on GPU + active_indices = cp.where(active_mask_gpu)[0] + + # Gravity + velocities_gpu[active_indices, 1] += 30.0 * dt + + # Position update + new_positions = positions_gpu + (velocities_gpu * dt) + + # Boundary checks + valid_x = (new_positions[:, 0] >= 0) & (new_positions[:, 0] < self.width) + valid_y = (new_positions[:, 1] >= 0) & (new_positions[:, 1] < self.height) + + # Floor collision: stick to bottom + floor_hit = (new_positions[:, 1] >= self.height) & active_mask_gpu + new_positions[floor_hit, 1] = self.height - 1 + velocities_gpu[floor_hit, 1] = 0 + + valid_particles = valid_x & valid_y & active_mask_gpu + + # Collision Map check (CuPy) + if "collision_map" in particles_data: + col_map_gpu = cp.asarray(particles_data["collision_map"]) + # Check new positions against collision map + ix = new_positions[:, 0].astype(cp.int32) + iy = new_positions[:, 1].astype(cp.int32) + + # Filter to only valid within bounds indices + safe_mask = valid_particles + if safe_mask.any(): + hits = col_map_gpu[ix[safe_mask], iy[safe_mask]] > 0 + + # For particles that hit, cancel move and stop velocity + # This is a bit tricky with CuPy indexing but possible + hits_indices = cp.where(safe_mask)[0][hits] + active_mask_gpu[hits_indices] = False # Or just stop them? + # Stopping them: + velocities_gpu[hits_indices] = 0 + valid_particles[hits_indices] = False + + # Particles that hit the floor are still "valid" and stay in the grid + active_mask_gpu = valid_particles | floor_hit + + positions_gpu[active_mask_gpu] = new_positions[active_mask_gpu] + + # Copy back to CPU + particles_data["positions"] = cp.asnumpy(positions_gpu) + particles_data["velocities"] = cp.asnumpy(velocities_gpu) + particles_data["active_mask"] = cp.asnumpy(active_mask_gpu) + + return particles_data + + def _update_particles_numba(self, particles_data: dict[str, np.ndarray], dt: float) -> dict[str, np.ndarray]: + """CPU-accelerated update using Numba""" + + self.apply_physics_numba( + particles_data["positions"], + particles_data["velocities"], + particles_data["particle_types"], + particles_data["active_mask"], + particles_data["is_gas"], + self.width, + self.height, + dt, + particles_data.get("collision_map", np.zeros((self.width, self.height), dtype=np.int32)) + ) + + self.handle_particle_interactions_numba( + particles_data["positions"], + particles_data["particle_types"], + particles_data["active_mask"], + self.width, + self.height + ) + + return particles_data + + def _update_particles_python(self, particles_data: dict[str, np.ndarray], dt: float) -> dict[str, np.ndarray]: + """Fallback pure Python update (slow)""" + + positions = particles_data["positions"] + velocities = particles_data["velocities"] + active_mask = particles_data["active_mask"] + + for i in range(len(positions)): + if active_mask[i]: + # Gravity matching sim.py + velocities[i, 1] += 30.0 * dt + + # Move + new_px = positions[i, 0] + velocities[i, 0] * dt + new_py = positions[i, 1] + velocities[i, 1] * dt + + if 0 <= new_px < self.width and 0 <= new_py < self.height: + # Collision check + ix, iy = int(new_px), int(new_py) + col_map = particles_data.get("collision_map") + if col_map is not None and col_map[ix, iy] > 0: + velocities[i] = 0 + else: + positions[i, 0] = new_px + positions[i, 1] = new_py + else: + active_mask[i] = False + + return particles_data + + def get_performance_info(self) -> dict[str, Any]: + """Get performance information and benchmark results""" + return { + "acceleration_mode": self.acceleration_mode, + "benchmarks": self.benchmark_results + } diff --git a/python/src/acceleration/simulation_bridge.py b/python/src/acceleration/simulation_bridge.py new file mode 100644 index 0000000..3fbf5e8 --- /dev/null +++ b/python/src/acceleration/simulation_bridge.py @@ -0,0 +1,349 @@ +""" +Bridge between HybridPhysicsEngine and existing Simulation class +=============================================================== + +Provides seamless integration without breaking existing code: +- Converts between existing particle format and acceleration-friendly arrays +- Maintains compatibility with current particle system +- Allows easy performance testing and fallback +""" + +from __future__ import annotations +import logging +import time +from typing import Any, TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from typing import Any + from .hybrid_physics import HybridPhysicsEngine + from ..physics.sim import Simulation + + +class AcceleratedSimulation: + """ + Wrapper that adds acceleration to existing Simulation class + """ + sim: "Simulation" + enable_acceleration: bool + hybrid_engine: HybridPhysicsEngine | None + performance_stats: dict[str, float | int] + _position_mapping: dict[int, tuple[int, int]] + + def __init__(self, original_sim: "Simulation", enable_acceleration: bool = True) -> None: + """ + Initialize accelerated simulation wrapper + + Args: + original_sim: Your existing Simulation instance + enable_acceleration: Whether to use acceleration (for testing) + """ + self.sim = original_sim + self.enable_acceleration = enable_acceleration + self._position_mapping = {} + + # Initialize hybrid engine if acceleration enabled + self.hybrid_engine = None + if enable_acceleration: + try: + from .hybrid_physics import HybridPhysicsEngine + self.hybrid_engine = HybridPhysicsEngine( + original_sim.width, + original_sim.height, + original_sim, + original_sim.particle_size + ) + logging.info("Acceleration initialized: %s", self.hybrid_engine.acceleration_mode) + except Exception as e: + logging.warning("Acceleration failed to initialize: %s", e) + self.enable_acceleration = False + + # Performance tracking + self.performance_stats = { + "accelerated_time": 0.0, + "original_time": 0.0, + "frame_count": 0, + "avg_speedup": 1.0 + } + + def apply_physics_accelerated(self, dt: float) -> None: + """ + Accelerated version of apply_physics - drop-in replacement + """ + + if not self.enable_acceleration or not self.hybrid_engine: + # Fallback to original implementation + return self.sim._simulate_step_python(dt) + + start_time = time.perf_counter() + + try: + # Convert existing particle data to acceleration format + particles_data = self._convert_to_acceleration_format() + + if particles_data["active_count"] == 0: + # No particles to process + return + + # Run accelerated physics + updated_data = self.hybrid_engine.update_particles(particles_data, dt) + + # Convert back to original format + self._convert_from_acceleration_format(updated_data) + + # Update performance stats + end_time = time.perf_counter() + self.performance_stats["accelerated_time"] += end_time - start_time + self.performance_stats["frame_count"] += 1 + + except Exception as e: + logging.warning("Acceleration failed, falling back to original: %s", e) + # Fallback to original implementation + return self.sim._simulate_step_python(dt) + + def _convert_to_acceleration_format(self) -> dict[str, Any]: + """Convert from Particle objects to numpy arrays for acceleration""" + + active_particles = list(self.sim.active_particles) + num_particles = len(active_particles) + + if num_particles == 0: + return {"active_count": 0} + + # Pre-allocate arrays + positions = np.zeros((num_particles, 2), dtype=np.float32) + velocities = np.zeros((num_particles, 2), dtype=np.float32) + particle_types = np.zeros(num_particles, dtype=np.int32) + active_mask = np.ones(num_particles, dtype=bool) + temperatures = np.zeros(num_particles, dtype=np.float32) + masses = np.zeros(num_particles, dtype=np.float32) + is_gas = np.zeros(num_particles, dtype=bool) + + # Map particle types to integers for faster processing + type_to_int = {ptype: i for i, ptype in enumerate(self.sim.particle_types)} + + # Store original positions for conversion back + self._position_mapping = {} + + for idx, pos in enumerate(active_particles): + x, y = int(pos[0]), int(pos[1]) + particle = self.sim.particles[x][y] + if particle is None: + active_mask[idx] = False + continue + + positions[idx] = [ + getattr(particle, 'float_x', float(x)), + getattr(particle, 'float_y', float(y)) + ] + velocities[idx] = particle.velocity + particle_types[idx] = type_to_int.get(particle.particle_type, 0) + is_gas[idx] = particle.is_gas + temperatures[idx] = getattr(particle, 'temperature', 20.0) + masses[idx] = particle.mass + + # Store mapping for conversion back + self._position_mapping[idx] = (x, y) + + # Create a collision map of static/solid particles for the GPU to respect + collision_map = np.zeros((self.sim.width, self.sim.height), dtype=np.int32) + # We only really care about "walls" or very heavy solids that don't move + for cx, cy in self.sim.occupied_cells: + p = self.sim.particles[cx][cy] + if p and p.particle_type in ["wall", "stone", "rock", "iron", "wood", "brass"]: + collision_map[cx, cy] = 1 + + return { + "positions": positions, + "velocities": velocities, + "particle_types": particle_types, + "active_mask": active_mask, + "temperatures": temperatures, + "masses": masses, + "is_gas": is_gas, + "collision_map": collision_map, + "active_count": num_particles + } + + def _convert_from_acceleration_format(self, updated_data: dict[str, Any]) -> None: + """Convert accelerated results back to Particle objects""" + + positions = updated_data["positions"] + velocities = updated_data["velocities"] + active_mask = updated_data["active_mask"] + temperatures = updated_data.get("temperatures") + + # Clear old positions and update with new ones + new_active_particles = set() + + # We need to track what changed in occupied_cells + old_occupied = set(self._position_mapping.values()) + new_occupied = set() + + from ..config.settings import engine_settings + wrap = engine_settings.get("wrap_particles", False) + + for idx in range(len(positions)): + old_x, old_y = self._position_mapping.get(idx, (0, 0)) + particle = self.sim.particles[old_x][old_y] + if particle is None: + continue + + if not active_mask[idx]: + # GPU flagged as inactive or out of bounds. + # Check if it's genuinely out of bounds vs just sleeping. + if not (0 <= particle.position[0] < self.sim.width and 0 <= particle.position[1] < self.sim.height): + # Delete + self.sim.particles[old_x][old_y] = None + self.sim.active_particles.discard((old_x, old_y)) + self.sim.occupied_cells.discard((old_x, old_y)) + self.sim._remove_from_spatial_grid(old_x, old_y) + self.sim.particle_count = max(0, self.sim.particle_count - 1) + else: + # Just sleeping, stays in new_occupied if it was there + new_occupied.add((old_x, old_y)) + continue + + # Get new positions + new_float_x, new_float_y = positions[idx, 0], positions[idx, 1] + + if wrap: + new_float_x %= self.sim.width + new_float_y %= self.sim.height + + new_x, new_y = int(new_float_x), int(new_float_y) + + # Bounds check for deletion if not wrapping + if not wrap and not (0 <= new_x < self.sim.width and 0 <= new_y < self.sim.height): + # Delete + self.sim.particles[old_x][old_y] = None + self.sim.active_particles.discard((old_x, old_y)) + self.sim.occupied_cells.discard((old_x, old_y)) + self.sim._remove_from_spatial_grid(old_x, old_y) + self.sim.particle_count = max(0, self.sim.particle_count - 1) + continue + + # Update particle properties + particle.velocity = velocities[idx].tolist() + particle.float_x = new_float_x + particle.float_y = new_float_y + + if temperatures is not None: + particle.temperature = float(temperatures[idx]) + + # Move particle if position changed + if (new_x, new_y) != (old_x, old_y): + # Check if new position is valid and empty + if self.sim.particles[new_x][new_y] is None: + # Move particle + self.sim.particles[old_x][old_y] = None + self.sim.particles[new_x][new_y] = particle + particle.position = (new_x, new_y) + + # Track movement for sets + new_active_particles.add((new_x, new_y)) + new_occupied.add((new_x, new_y)) + self.sim._remove_from_spatial_grid(old_x, old_y) + self.sim._add_to_spatial_grid(new_x, new_y) + else: + # Collision or occupied - stay at old position + new_active_particles.add((old_x, old_y)) + new_occupied.add((old_x, old_y)) + else: + # Position unchanged + new_active_particles.add((old_x, old_y)) + new_occupied.add((old_x, old_y)) + + # Sync sets + self.sim.active_particles = new_active_particles + self.sim.occupied_cells.update(new_occupied) + # Remove old ones that are no longer occupied + for pos in old_occupied: + if self.sim.particles[pos[0]][pos[1]] is None: + self.sim.occupied_cells.discard(pos) + self.sim._remove_from_spatial_grid(pos[0], pos[1]) + + def benchmark_performance(self, duration_seconds: float = 5.0) -> dict[str, Any]: + """ + Benchmark accelerated vs original physics performance + + Args: + duration_seconds: How long to run benchmark + + Returns: + Performance comparison results + """ + + logging.info("Starting %ss physics benchmark...", duration_seconds) + + # Save current state + original_particles = {} + for x, y in self.sim.active_particles: + if self.sim.particles[x][y]: + original_particles[(x, y)] = self.sim.particles[x][y] + + # Benchmark original physics + self.enable_acceleration = False + original_start = time.perf_counter() + original_frames = 0 + + while time.perf_counter() - original_start < duration_seconds / 2: + self.sim._simulate_step_python(0.016) # 60 FPS + original_frames += 1 + + original_time = time.perf_counter() - original_start + + # Restore state + self.sim.active_particles.clear() + for (x, y), particle in original_particles.items(): + self.sim.particles[x][y] = particle + self.sim.active_particles.add((x, y)) + + # Benchmark accelerated physics + self.enable_acceleration = True + accelerated_start = time.perf_counter() + accelerated_frames = 0 + + while time.perf_counter() - accelerated_start < duration_seconds / 2: + self.apply_physics_accelerated(0.016) + accelerated_frames += 1 + + accelerated_time = time.perf_counter() - accelerated_start + + # Calculate results + original_fps = original_frames / original_time + accelerated_fps = accelerated_frames / accelerated_time + speedup = accelerated_fps / original_fps if original_fps > 0 else 1.0 + + results = { + "original_fps": original_fps, + "accelerated_fps": accelerated_fps, + "speedup": speedup, + "acceleration_mode": self.hybrid_engine.acceleration_mode if self.hybrid_engine else "none", + "original_frames": original_frames, + "accelerated_frames": accelerated_frames, + "particle_count": len(self.sim.active_particles), + "performance_info": self.hybrid_engine.get_performance_info() if self.hybrid_engine else {} + } + + logging.info("Benchmark complete: %.2fx speedup (%.1f vs %.1f FPS)", + speedup, accelerated_fps, original_fps) + + return results + + def get_acceleration_info(self) -> dict[str, Any]: + """Get information about current acceleration status""" + + if not self.hybrid_engine: + return {"acceleration": "disabled", "reason": "not initialized"} + + info = self.hybrid_engine.get_performance_info() + info["current_particles"] = len(self.sim.active_particles) + info["frame_count"] = self.performance_stats["frame_count"] + + if self.performance_stats["frame_count"] > 0: + avg_time = float(self.performance_stats["accelerated_time"]) / int(self.performance_stats["frame_count"]) + info["avg_frame_time_ms"] = avg_time * 1000 + + return info diff --git a/src/debug/__init__.py b/python/src/config/__init__.py similarity index 100% rename from src/debug/__init__.py rename to python/src/config/__init__.py diff --git a/src/config/py.typed b/python/src/config/py.typed similarity index 100% rename from src/config/py.typed rename to python/src/config/py.typed diff --git a/src/config/settings.py b/python/src/config/settings.py similarity index 92% rename from src/config/settings.py rename to python/src/config/settings.py index b857951..369d4ca 100644 --- a/src/config/settings.py +++ b/python/src/config/settings.py @@ -42,13 +42,17 @@ engine_settings = { "enable_PVisuals": False, "enable_TempVisuals": False, "outerwall": False, + "enable_acceleration": False, + "fast_sim": False, # 'settings': True/False } -def load_particle_properties(): +from typing import Any + +def load_particle_properties() -> dict[str, dict[str, Any]]: """Loads particle properties from a JSON file.""" - particle_data = {} + particle_data: dict[str, dict[str, Any]] = {} base_path = os.path.join(os.path.dirname(__file__), "..") core_path = os.path.join(base_path, "part", "coreparts") mods_path = os.path.join(base_path, "part", "mods") diff --git a/src/part/__init__.py b/python/src/debug/__init__.py similarity index 100% rename from src/part/__init__.py rename to python/src/debug/__init__.py diff --git a/src/debug/debugger_system.py b/python/src/debug/debugger_system.py similarity index 96% rename from src/debug/debugger_system.py rename to python/src/debug/debugger_system.py index dd5513a..6786257 100644 --- a/src/debug/debugger_system.py +++ b/python/src/debug/debugger_system.py @@ -67,10 +67,10 @@ class DebuggerSystem: def update_system_metrics(self): """Track system resource usage""" - self.performance_metrics["memory_usage"] = ( + self.performance_metrics["memory_usage"] = int( process.memory_info().rss / 1024 / 1024 ) # MB - self.performance_metrics["cpu_usage"] = process.cpu_percent() + self.performance_metrics["cpu_usage"] = int(process.cpu_percent()) def draw_debug_overlay(self, screen, sim): """Draws the debug overlay on the screen.""" diff --git a/src/physics/__init__.py b/python/src/part/__init__.py similarity index 100% rename from src/physics/__init__.py rename to python/src/part/__init__.py diff --git a/src/part/coreparts/particles/basic.json b/python/src/part/coreparts/particles/basic.json similarity index 70% rename from src/part/coreparts/particles/basic.json rename to python/src/part/coreparts/particles/basic.json index 2ad6ad4..e963c32 100644 --- a/src/part/coreparts/particles/basic.json +++ b/python/src/part/coreparts/particles/basic.json @@ -3,11 +3,16 @@ "name": "Sand", "size": 1, "hardness": 0.5, - "color": [255, 255, 0, 255], + "color": [ + 255, + 255, + 0, + 255 + ], "velocity": 0.5, "mass": 0.5, "temperature": 20, - "melt": "molten-glass", + "melt": "molten_glass", "melt_temperature": 1700, "friction": 0.5, "liquid": false, @@ -18,7 +23,12 @@ "name": "Wet Sand", "size": 1, "hardness": 0.5, - "color": [200, 200, 25, 255], + "color": [ + 200, + 200, + 25, + 255 + ], "velocity": 0.5, "mass": 0.5, "conductivity": 0, @@ -27,7 +37,11 @@ "temperature": 20, "explosive": false, "explosion_radius": 0, - "explosion_color": [0, 0, 0], + "explosion_color": [ + 0, + 0, + 0 + ], "friction": 0.5, "viscosity": 0.3, "pressure": 0, @@ -45,15 +59,24 @@ "velocity": 0.3, "conductivity": 1, "heat_capacity": 1, - "color": [128, 128, 128, 255], + "color": [ + 128, + 128, + 128, + 255 + ], "mass": 0.8, "flamability": 0, - "melt": "molten-rock", - "melt_temperature": 600, + "melt": "molten_rock", + "melt_temperature": 1200, "temperature": 20, "explosive": false, "explosion_radius": 0, - "explosion_color": [0, 0, 0], + "explosion_color": [ + 0, + 0, + 0 + ], "friction": 0.5, "viscosity": 0.5, "liquid": false, @@ -64,7 +87,12 @@ "name": "Dirt", "size": 1, "hardness": 0.5, - "color": [139, 69, 19, 255], + "color": [ + 139, + 69, + 19, + 255 + ], "velocity": 0.5, "mass": 0.5, "temperature": 20, @@ -78,13 +106,18 @@ "size": 1, "hardness": 0.7, "velocity": 1.5, - "color": [128, 128, 128, 220], + "color": [ + 128, + 128, + 128, + 220 + ], "mass": 1, "durability": 100.0, "Broken": "brkstone", "temperature": 20, - "melt": "molten-Stone", - "melt_temperature": 800, + "melt": "molten_stone", + "melt_temperature": 1200, "friction": 0.5, "liquid": false, "solid": true, @@ -95,7 +128,11 @@ "size": 1, "hardness": 0.4, "velocity": 0.5, - "color": [128, 128, 128, 220 + "color": [ + 128, + 128, + 128, + 220 ], "mass": 0.5, "temperature": 20, diff --git a/src/part/coreparts/particles/gases.json b/python/src/part/coreparts/particles/gases.json similarity index 74% rename from src/part/coreparts/particles/gases.json rename to python/src/part/coreparts/particles/gases.json index f30c93a..52f82c9 100644 --- a/src/part/coreparts/particles/gases.json +++ b/python/src/part/coreparts/particles/gases.json @@ -4,12 +4,17 @@ "size": 1, "hardness": 0.01, "velocity": 0.3, - "conductivity": 1, + "conductivity": 0.1, "heat_capacity": 1, - "color": [255, 255, 255, 255], + "color": [ + 255, + 255, + 255, + 255 + ], "mass": 0.01, "temperature": 100, - "solidify_temperature": 98, + "solidify_temperature": 50, "solidify": "water", "friction": 0.5, "viscosity": 0.5, @@ -24,7 +29,12 @@ "velocity": 0.07, "conductivity": 0, "heat_capacity": 1, - "color": [115, 113, 95, 255], + "color": [ + 115, + 113, + 95, + 255 + ], "mass": 0.01, "temperature": 85, "friction": 0.4, @@ -41,7 +51,12 @@ "velocity": 0.0, "conductivity": 0, "heat_capacity": 1, - "color": [255, 255, 255, 25], + "color": [ + 255, + 255, + 255, + 25 + ], "mass": 0.0001, "temperature": 0, "friction": 0.0, diff --git a/src/part/coreparts/particles/liquids.json b/python/src/part/coreparts/particles/liquids.json similarity index 100% rename from src/part/coreparts/particles/liquids.json rename to python/src/part/coreparts/particles/liquids.json diff --git a/python/src/part/coreparts/particles/metals.json b/python/src/part/coreparts/particles/metals.json new file mode 100644 index 0000000..7b5c693 --- /dev/null +++ b/python/src/part/coreparts/particles/metals.json @@ -0,0 +1,65 @@ +{ + "iron": { + "name": "Iron", + "size": 1, + "hardness": 0.8, + "conductivity": 0.9, + "heat_capacity": 0.5, + "color": [ + 160, + 160, + 160, + 255 + ], + "mass": 1.2, + "temperature": 20, + "melt": "molten_iron", + "melt_temperature": 1538, + "friction": 0.6, + "solid": true, + "liquid": false, + "is_gas": false + }, + "gold": { + "name": "Gold", + "size": 1, + "hardness": 0.2, + "conductivity": 1.0, + "heat_capacity": 0.3, + "color": [ + 255, + 215, + 0, + 255 + ], + "mass": 2.0, + "temperature": 20, + "melt": "molten_gold", + "melt_temperature": 1064, + "friction": 0.4, + "solid": true, + "liquid": false, + "is_gas": false + }, + "copper": { + "name": "Copper", + "size": 1, + "hardness": 0.5, + "conductivity": 0.95, + "heat_capacity": 0.4, + "color": [ + 184, + 115, + 51, + 255 + ], + "mass": 1.1, + "temperature": 20, + "melt": "molten_copper", + "melt_temperature": 1085, + "friction": 0.5, + "solid": true, + "liquid": false, + "is_gas": false + } +} \ No newline at end of file diff --git a/src/part/coreparts/particles/solids.json b/python/src/part/coreparts/particles/solids.json similarity index 71% rename from src/part/coreparts/particles/solids.json rename to python/src/part/coreparts/particles/solids.json index a512c44..6ee68d2 100644 --- a/src/part/coreparts/particles/solids.json +++ b/python/src/part/coreparts/particles/solids.json @@ -6,7 +6,12 @@ "velocity": 0.0, "conductivity": 0, "heat_capacity": 0, - "color": [75, 75, 75, 255], + "color": [ + 75, + 75, + 75, + 255 + ], "mass": 1, "temperature": 20, "friction": 1, @@ -22,12 +27,22 @@ "velocity": 0.5, "conductivity": 0, "heat_capacity": 1, - "color": [139, 69, 19, 255], + "color": [ + 139, + 69, + 19, + 255 + ], "mass": 0.5, "flamability": 0.8, "burning_temperature": 250, "burning_rate": 0.01, - "burning_color": [255, 69, 19, 255], + "burning_color": [ + 255, + 69, + 19, + 255 + ], "temperature": 20, "friction": 0.5, "viscosity": 0.5, @@ -42,11 +57,16 @@ "velocity": 0.4, "conductivity": 0, "heat_capacity": 1, - "color": [50, 45, 255, 100], + "color": [ + 50, + 45, + 255, + 100 + ], "mass": 0.6, "temperature": 20, - "melt": "molten-glass", - "melt_temperature": 600, + "melt": "molten_glass", + "melt_temperature": 1500, "friction": 0.7, "viscosity": 0.9, "liquid": false, @@ -60,7 +80,12 @@ "velocity": 0.5, "conductivity": 1, "heat_capacity": 1, - "color": [125, 45, 55, 255], + "color": [ + 125, + 45, + 55, + 255 + ], "mass": 0.5, "melt": "dirt", "melt_temperature": 100, @@ -68,7 +93,11 @@ "temperature": 20, "explosive": false, "explosion_radius": 0, - "explosion_color": [0, 0, 0], + "explosion_color": [ + 0, + 0, + 0 + ], "friction": 0.5, "viscosity": 1, "liquid": false, diff --git a/src/part/coreparts/special/effects.json b/python/src/part/coreparts/special/effects.json similarity index 60% rename from src/part/coreparts/special/effects.json rename to python/src/part/coreparts/special/effects.json index 6e4ffb4..5a58a17 100644 --- a/src/part/coreparts/special/effects.json +++ b/python/src/part/coreparts/special/effects.json @@ -6,7 +6,12 @@ "velocity": 0.8, "conductivity": 1, "heat_capacity": 1, - "color": [255, 100, 200, 255], + "color": [ + 255, + 100, + 200, + 255 + ], "mass": 0.01, "temperature": 3400, "liquid": false, @@ -14,23 +19,38 @@ "is_gas": true, "glow_radius": 1, "glow_intensity": 0.8, - "glow_color": [255, 150, 220, 180] + "glow_color": [ + 255, + 150, + 220, + 180 + ] }, "spark": { "name": "Spark", "size": 1, "hardness": 0.1, - "velocity": 0.8, + "velocity": 0.0, "conductivity": 1, "heat_capacity": 1, - "color": [255, 255, 0, 255], + "color": [ + 255, + 255, + 0, + 255 + ], "mass": 0.01, "temperature": 900, - "lifetime": 30, + "lifetime": 1, "glow_radius": 3, "glow_intensity": 1.0, - "glow_color": [255, 200, 0, 200], - "produces_on_death": "smoke", + "glow_color": [ + 255, + 200, + 0, + 200 + ], + "produces_on_death": null, "liquid": false, "solid": false, "is_gas": true @@ -41,12 +61,22 @@ "hardness": 0.0, "velocity": 0.5, "conductivity": 1, - "color": [0, 255, 255, 180], + "color": [ + 0, + 255, + 255, + 180 + ], "mass": 0.0, "temperature": 1000, "glow_radius": 4, "glow_intensity": 0.9, - "glow_color": [50, 255, 255, 150], + "glow_color": [ + 50, + 255, + 255, + 150 + ], "liquid": false, "solid": false, "is_gas": true, diff --git a/src/part/coreparts/special/forces.json b/python/src/part/coreparts/special/forces.json similarity index 100% rename from src/part/coreparts/special/forces.json rename to python/src/part/coreparts/special/forces.json diff --git a/src/part/coreparts/states/burning.json b/python/src/part/coreparts/states/burning.json similarity index 72% rename from src/part/coreparts/states/burning.json rename to python/src/part/coreparts/states/burning.json index 2797627..b033a56 100644 --- a/src/part/coreparts/states/burning.json +++ b/python/src/part/coreparts/states/burning.json @@ -6,7 +6,12 @@ "velocity": 0.1, "conductivity": 0, "heat_capacity": 1, - "color": [255, 0, 0, 255], + "color": [ + 255, + 0, + 0, + 255 + ], "mass": 0.1, "flamability": 1, "temperature": 800, @@ -14,7 +19,9 @@ "viscosity": 0.1, "liquid": false, "solid": false, - "is_gas": true + "is_gas": false, + "burning": true, + "burn_duration": 150 }, "burning_wood": { "name": "Burning Wood", @@ -23,7 +30,12 @@ "velocity": 0.5, "conductivity": 0, "heat_capacity": 1, - "color": [255, 69, 19, 255], + "color": [ + 255, + 69, + 19, + 255 + ], "mass": 0.5, "flamability": 0.8, "temperature": 251, @@ -35,7 +47,8 @@ "is_gas": false, "produces": "smoke", "burn_rate": 0.05, - "heat_emission": 50 + "heat_emission": 50, + "burn_duration": 500 }, "ember": { "name": "Ember", @@ -44,14 +57,20 @@ "velocity": 0.2, "conductivity": 1, "heat_capacity": 1, - "color": [255, 140, 0, 200], + "color": [ + 255, + 140, + 0, + 200 + ], "mass": 0.3, "temperature": 500, - "lifetime": 60, + "lifetime": 30, "heat_emission": 30, "produces": "smoke", "friction": 0.3, "viscosity": 0.3, + "burning": true, "liquid": false, "solid": true, "is_gas": false diff --git a/src/part/coreparts/states/frozen.json b/python/src/part/coreparts/states/frozen.json similarity index 64% rename from src/part/coreparts/states/frozen.json rename to python/src/part/coreparts/states/frozen.json index 5af4e43..38e7994 100644 --- a/src/part/coreparts/states/frozen.json +++ b/python/src/part/coreparts/states/frozen.json @@ -4,13 +4,18 @@ "size": 1, "hardness": 1000, "velocity": 0.0, - "conductivity": 0, - "heat_capacity": 0, - "color": [75, 75, 170, 255], + "conductivity": 0.001, + "heat_capacity": 1.0, + "color": [ + 75, + 75, + 170, + 255 + ], "mass": 1, "temperature": 0, "melt": "water", - "melt_temperature": 0.05, + "melt_temperature": 7.0, "friction": 1, "viscosity": 1, "liquid": false, @@ -22,12 +27,17 @@ "size": 1, "hardness": 0.1, "velocity": 0.2, - "conductivity": 1, + "conductivity": 0.002, "heat_capacity": 1, - "color": [255, 255, 255, 255], + "color": [ + 255, + 255, + 255, + 255 + ], "mass": 0.01, "melt": "water", - "melt_temperature": 5, + "melt_temperature": 9.0, "temperature": 0, "friction": 0.1, "viscosity": 0.01, diff --git a/python/src/part/coreparts/states/molten.json b/python/src/part/coreparts/states/molten.json new file mode 100644 index 0000000..5ef1662 --- /dev/null +++ b/python/src/part/coreparts/states/molten.json @@ -0,0 +1,137 @@ +{ + "molten_stone": { + "name": "Molten Stone", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [ + 255, + 140, + 0, + 255 + ], + "mass": 0.8, + "temperature": 1200, + "solidify": "stone", + "solidify_temperature": 800, + "friction": 0.8, + "viscosity": 0.8, + "liquid": true, + "solid": false, + "is_gas": false + }, + "molten_glass": { + "name": "Molten Glass", + "size": 1, + "hardness": 0.2, + "velocity": 0.4, + "conductivity": 0.8, + "heat_capacity": 1, + "color": [ + 255, + 200, + 150, + 200 + ], + "mass": 0.6, + "temperature": 1700, + "solidify": "glass", + "solidify_temperature": 599, + "friction": 0.7, + "viscosity": 0.9, + "liquid": true, + "solid": false, + "is_gas": false + }, + "molten_rock": { + "name": "Molten Rock", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [ + 255, + 140, + 0, + 255 + ], + "mass": 0.8, + "temperature": 1200, + "solidify": "rock", + "solidify_temperature": 300, + "friction": 0.8, + "viscosity": 0.8, + "liquid": true, + "solid": false, + "is_gas": false + }, + "molten_iron": { + "name": "Molten Iron", + "size": 1, + "hardness": 0.1, + "conductivity": 0.8, + "heat_capacity": 0.6, + "color": [ + 255, + 100, + 0, + 255 + ], + "mass": 1.1, + "temperature": 1600, + "solidify": "iron", + "solidify_temperature": 1230, + "friction": 0.5, + "viscosity": 0.7, + "liquid": true, + "solid": false, + "is_gas": false + }, + "molten_gold": { + "name": "Molten Gold", + "size": 1, + "hardness": 0.1, + "conductivity": 0.9, + "heat_capacity": 0.4, + "color": [ + 255, + 200, + 50, + 255 + ], + "mass": 1.9, + "temperature": 1100, + "solidify": "gold", + "solidify_temperature": 677, + "friction": 0.3, + "viscosity": 0.6, + "liquid": true, + "solid": false, + "is_gas": false + }, + "molten_copper": { + "name": "Molten Copper", + "size": 1, + "hardness": 0.1, + "conductivity": 0.85, + "heat_capacity": 0.5, + "color": [ + 255, + 130, + 50, + 255 + ], + "mass": 1.0, + "temperature": 1150, + "solidify": "copper", + "solidify_temperature": 766, + "friction": 0.4, + "viscosity": 0.6, + "liquid": true, + "solid": false, + "is_gas": false + } +} \ No newline at end of file diff --git a/src/part/mods/test.json b/python/src/part/mods/test.json similarity index 100% rename from src/part/mods/test.json rename to python/src/part/mods/test.json diff --git a/src/part/particles_Historical.json b/python/src/part/particles_Historical.json similarity index 100% rename from src/part/particles_Historical.json rename to python/src/part/particles_Historical.json diff --git a/src/rendering/__init__.py b/python/src/physics/__init__.py similarity index 100% rename from src/rendering/__init__.py rename to python/src/physics/__init__.py diff --git a/python/src/physics/grid_system.py b/python/src/physics/grid_system.py new file mode 100644 index 0000000..523d765 --- /dev/null +++ b/python/src/physics/grid_system.py @@ -0,0 +1,96 @@ +"""_summary_ + +:return: _description_ +:rtype: _type_ +""" + +from typing import Any + + +class GridSystem: + """this is the grid system.""" + + def __init__(self, width, height, cell_size): + self.width = width + self.height = height + self.cell_size = cell_size + self.spatial_grid = {} + self.active_particles = set() + self.particles = [[None for _ in range(height)] for _ in range(width)] + + def get_cell_key(self, x, y): + """Convert coordinates to grid cell""" + cell_x = x // self.cell_size + cell_y = y // self.cell_size + return (cell_x, cell_y) + + def add_to_spatial_grid(self, x, y): + """this is where we add to the spatial grid.""" + cell_key = self.get_cell_key(x, y) + if cell_key not in self.spatial_grid: + self.spatial_grid[cell_key] = set() + self.spatial_grid[cell_key].add((x, y)) + return cell_key + + def remove_from_spatial_grid(self, x, y): + """this is where we remove from the spatial grid.""" + cell_key = self.get_cell_key(x, y) + if cell_key in self.spatial_grid: + self.spatial_grid[cell_key].discard((x, y)) + return cell_key + + def get_neighbors_from_grid(self, x, y): + """Get neighbors using spatial grid""" + cell_key = self.get_cell_key(x, y) + neighbors = [] + # Check current and adjacent cells + for dx in [-1, 0, 1]: + for dy in [-1, 0, 1]: + check_key = (cell_key[0] + dx, cell_key[1] + dy) + if check_key in self.spatial_grid: + neighbors.extend(self.spatial_grid[check_key]) + + return neighbors + + def update_spatial_grid(self): + """Enhanced spatial grid update""" + if len(self.active_particles) > 100: + self.spatial_grid = {} + cell_lists: dict[Any, Any] = {} + + # Track temperature-sensitive particles + temp_sensitive_cells = set() + + for x, y in self.active_particles: + cell_key = (x // self.cell_size, y // self.cell_size) + if cell_key not in cell_lists: + cell_lists[cell_key] = [] + cell_lists[cell_key].append((x, y)) + + # Mark cells with temperature-sensitive particles + particle = self.particles[x][y] + if hasattr(particle, "solidify_temperature") or hasattr( + particle, "melt_temperature" + ): + temp_sensitive_cells.add(cell_key) + + self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} + return temp_sensitive_cells + + def split_into_processing_regions(self, num_cores): + """Split spatial grid into regions for parallel processing""" + regions = {} + cell_keys = list(self.spatial_grid.keys()) + cells_per_region = len(cell_keys) // num_cores + + for i in range(num_cores): + start_idx = i * cells_per_region + end_idx = ( + start_idx + cells_per_region + if i < num_cores - 1 + else len(cell_keys) + ) + region_keys = cell_keys[start_idx:end_idx] + regions[i] = {key: self.spatial_grid[key] for key in region_keys} + + return regions diff --git a/python/src/physics/particle.py b/python/src/physics/particle.py new file mode 100644 index 0000000..ee2f92f --- /dev/null +++ b/python/src/physics/particle.py @@ -0,0 +1,218 @@ +from __future__ import annotations +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + from .sim import Simulation + + +from .particle_types import ParticlePropertyValue + + +class Container: + items: dict[int, "Particle"] + + def __init__(self) -> None: + self.items = {} + + def __setitem__(self, key: int, value: "Particle") -> None: + self.items[key] = value + + +class Particle: + """Set up a particle with the given properties.""" + + # Class Attribute Guidelines + position: list[float] | tuple[float, float] # (x, y) + velocity: list[float] # (vx, vy) + mass: float + particle_type: str + sim: Simulation + + # Core properties + size: int # Size of the particle + hardness: float # How hard the particle is + color: list[int] | tuple[int, int, int] | tuple[int, int, int, int] # (r, g, b, a) + temperature: float # Temperature of the particle + durability: float # How durable the particle is + + # Physics properties + conductivity: float # How conductive the particle is + heat_capacity: float # How much heat the particle can store + flamability: float # How flammable the particle is + friction: float # How much friction the particle has + viscosity: float # How viscous the particle is + pressure: float # How much pressure the particle has + + # State properties + liquid: bool # Whether the particle is liquid + solid: bool # Whether the particle is solid + is_gas: bool # Whether the particle is gas + + # Temperature transition properties + melt: str | None # The particle that is created when the particle melts + melt_temperature: float | None # The temperature at which the particle melts + solidify: str | None # The particle that is created when the particle solidifies + solidify_temperature: float | None # The temperature at which the particle solidifies + evaporate: str | None # The particle that is created when the particle evaporates + evaporate_temperature: float | None # The temperature at which the particle evaporates + freeze: str | None # The particle that is created when the particle freezes + freeze_temperature: float | None # The temperature at which the particle freezes + + # Special properties + explosive: bool # Whether the particle is explosive + explosion_radius: int # The radius of the explosion + explosion_color: list[int] # The color of the explosion + explosion_force: float # The force of the explosion + explosion_duration: int # The duration of the explosion + + # Pressure properties + pressure_resistance: float # How much pressure the particle can withstand + pressure_tolerance: float # How much pressure the particle can withstand before breaking + pressure_threshold: float # The pressure at which the particle breaks + pressure_threshold_duration: int # The duration of the pressure threshold + + # Burning properties + burning: bool # Whether the particle is burning + burn_temperature: float # The temperature at which the particle burns + burn_duration: int # The duration of the burn + burn_color: list[int] # The color of the burn + burn_rate: float # The rate at which the particle burns + burn_intensity: float # The intensity of the burn + burn_rate_multiplier: float # The multiplier for the burn rate + burn_time: float + + # Spark properties + spark_time: int # Frames remaining for spark (positive: sparked, negative: cooldown) + + # Breakage properties + breakable: float | None # How easily the particle can be broken + broken: str | None # The particle that is created when the particle is broken + lifetime: float | None # How long the particle lives before dying + + def __init__( + self, + simulation: Simulation, + position: list[float] | tuple[float, float], + velocity: list[float], + mass: float, + particle_type: str, + properties: dict[str, ParticlePropertyValue], + temperature: float = 20, + ) -> None: + self.position = position # (x, y) + self.velocity = velocity # (vx, vy) + self.mass = mass + self.particle_type = particle_type + self.sim = simulation + self.float_x: float = float(position[0]) + self.float_y: float = float(position[1]) + + # Helper for type safe extraction + def get_float(key: str, default: float) -> float: + val = properties.get(key, default) + # Safe because we expect these keys to be numeric in usage, even if type definition allows others + return float(cast(int | float | str, val)) + + def get_int(key: str, default: int) -> int: + val = properties.get(key, default) + return int(cast(int | float | str, val)) + + def get_bool(key: str, default: bool) -> bool: + val = properties.get(key, default) + return bool(val) + + def get_str(key: str) -> str | None: + val = properties.get(key) + return str(val) if val is not None else None + + # Core properties + self.size = get_int("size", 1) + self.hardness = get_float("hardness", 0.5) + + val = properties.get("color", [255, 255, 255, 255]) + if isinstance(val, (list, tuple)): + self.color = val + else: + self.color = [255, 255, 255, 255] + + self.temperature = get_float("temperature", temperature) + self.durability = get_float("durability", 100.0) + + # Physics properties + self.conductivity = get_float("conductivity", 0) + self.heat_capacity = get_float("heat_capacity", 1) + self.flamability = get_float("flamability", 0.0) + self.friction = get_float("friction", 0.5) + self.viscosity = get_float("viscosity", 1.0) + self.pressure = get_float("pressure", 0) + + # State properties + self.liquid = get_bool("liquid", False) + self.solid = get_bool("solid", True) + self.is_gas = get_bool("is_gas", False) + + # Temperature transition properties + self.melt = get_str("melt") + self.melt_temperature = get_float("melt_temperature", 0) if properties.get("melt_temperature") is not None else None + self.solidify = get_str("solidify") + self.solidify_temperature = get_float("solidify_temperature", 0) if properties.get("solidify_temperature") is not None else None + + self.evaporate = get_str("evaporate") + self.evaporate_temperature = get_float("evaporate_temperature", 0) if properties.get("evaporate_temperature") is not None else None + + self.freeze = get_str("freeze") + self.freeze_temperature = get_float("freeze_temperature", 0) if properties.get("freeze_temperature") is not None else None + + # Special properties + self.explosive = get_bool("explosive", False) + self.explosion_radius = get_int("explosion_radius", 0) + + e_color = properties.get("explosion_color", [0, 0, 0]) + if isinstance(e_color, list): + self.explosion_color = e_color + else: + self.explosion_color = [0, 0, 0] + + self.explosion_force = get_float("explosion_force", 0) + self.explosion_duration = get_int("explosion_duration", 0) + + # Pressure properties + self.pressure_resistance = get_float("pressure_resistance", 0) + self.pressure_tolerance = get_float("pressure_tolerance", 0) + self.pressure_threshold = get_float("pressure_threshold", 0) + self.pressure_threshold_duration = get_int("pressure_threshold_duration", 0) + + # Burning properties + self.burning = get_bool("burning", False) + self.burn_temperature = get_float("burn_temperature", 0) + self.burn_duration = get_int("burn_duration", 0) + + b_color = properties.get("burn_color", [255, 0, 0]) + self.burn_color = b_color if isinstance(b_color, list) else [255, 0, 0] + + self.burn_rate = get_float("burn_rate", 0) + self.burn_intensity = get_float("burn_intensity", 0) + self.burn_rate_multiplier = get_float("burn_rate_multiplier", 1.0) + self.burn_time = float(self.burn_duration) + self.spark_time = 0 + + # Breakage properties + self.breakable = get_float("breakable", 0) if properties.get("breakable") is not None else None + self.broken = get_str("broken") + self.lifetime = get_float("lifetime", 0) if properties.get("lifetime") is not None else None + + @classmethod + def from_type(cls, simulation: Simulation, position: list[float] | tuple[float, float], particle_type: str, properties: dict[str, ParticlePropertyValue]) -> "Particle": + """Pre-initialize a particle with default values based on its type.""" + default_velocity: list[float] = [0.0, 0.0] + val = properties.get("mass", 1.0) + default_mass: float = float(cast(int | float | str, val)) + + return cls( + simulation, + position, + default_velocity, + default_mass, + particle_type, + properties, + ) diff --git a/python/src/physics/particle_types.py b/python/src/physics/particle_types.py new file mode 100644 index 0000000..e2587cd --- /dev/null +++ b/python/src/physics/particle_types.py @@ -0,0 +1,4 @@ +from typing import Any, TypeAlias + +# Type for particle properties loaded from JSON +ParticlePropertyValue: TypeAlias = Any diff --git a/python/src/physics/sim.py b/python/src/physics/sim.py new file mode 100644 index 0000000..3cd953b --- /dev/null +++ b/python/src/physics/sim.py @@ -0,0 +1,1924 @@ +from __future__ import annotations +import random +import time +from typing import Any + +import numpy as np + +try: + from numba import njit + NUMBA_AVAILABLE = True +except (ImportError, ModuleNotFoundError): + NUMBA_AVAILABLE = False + + def njit(*args: Any, **kwargs: Any): + if args and callable(args[0]): + return args[0] + return lambda f: f + +KIND_SOLID = 1 +KIND_LIQUID = 2 +KIND_GAS = 3 + + +@njit(cache=True) +def _fast_move( + type_id: np.ndarray, + temp: np.ndarray, + burn_time: np.ndarray, + burning: np.ndarray, + spark_time: np.ndarray, + lifetime: np.ndarray, + ambient_temp: float, + x: int, + y: int, + nx: int, + ny: int, + swap: int, +) -> None: + if swap == 0: + type_id[nx, ny] = type_id[x, y] + temp[nx, ny] = temp[x, y] + burn_time[nx, ny] = burn_time[x, y] + burning[nx, ny] = burning[x, y] + spark_time[nx, ny] = spark_time[x, y] + lifetime[nx, ny] = lifetime[x, y] + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + return + + t2 = type_id[nx, ny] + tmp2 = temp[nx, ny] + bt2 = burn_time[nx, ny] + b2 = burning[nx, ny] + st2 = spark_time[nx, ny] + lt2 = lifetime[nx, ny] + + type_id[nx, ny] = type_id[x, y] + temp[nx, ny] = temp[x, y] + burn_time[nx, ny] = burn_time[x, y] + burning[nx, ny] = burning[x, y] + spark_time[nx, ny] = spark_time[x, y] + lifetime[nx, ny] = lifetime[x, y] + + type_id[x, y] = t2 + temp[x, y] = tmp2 + burn_time[x, y] = bt2 + burning[x, y] = b2 + spark_time[x, y] = st2 + lifetime[x, y] = lt2 + + +@njit(cache=True) +def _fast_step_numba( + type_id: np.ndarray, + temp: np.ndarray, + burn_time: np.ndarray, + burning: np.ndarray, + spark_time: np.ndarray, + lifetime: np.ndarray, + type_kind: np.ndarray, + type_static: np.ndarray, + type_mass: np.ndarray, + type_conductivity: np.ndarray, + type_flamability: np.ndarray, + type_burning_init: np.ndarray, + type_burn_duration: np.ndarray, + type_burn_temp: np.ndarray, + type_temp: np.ndarray, + type_evap_target: np.ndarray, + type_evap_temp: np.ndarray, + type_melt_target: np.ndarray, + type_melt_temp: np.ndarray, + type_solidify_target: np.ndarray, + type_solidify_temp: np.ndarray, + type_freeze_target: np.ndarray, + type_freeze_temp: np.ndarray, + type_lifetime: np.ndarray, + width: int, + height: int, + frame: int, + dt: float, + wrap: int, + wind_x: float, + wind_y: float, + ambient_temp: float, + air_conductivity: float, + id_fire: int, + id_lava: int, + id_water: int, + id_sand: int, + id_wsand: int, + id_dirt: int, + id_mud: int, + id_stone: int, + id_steam: int, + id_acid: int, + id_spark: int, + id_energy: int, + id_wall: int, + id_smoke: int, + id_glass: int, +) -> None: + for y in range(height - 1, -1, -1): + for x in range(width): + t = type_id[x, y] + if t == 0: + continue + + # RNG seed + seed = np.uint32(x * 73856093) ^ np.uint32(y * 19349663) ^ np.uint32(frame * 83492791) + seed = (seed ^ (seed >> 13)) * np.uint32(1274126177) + left_first = (seed & np.uint32(1)) == 0 + + # Lifetime handling + if lifetime[x, y] > 0.0: + lifetime[x, y] -= dt * 60.0 + if lifetime[x, y] <= 0.0: + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + continue + + # Spark logic + st = spark_time[x, y] + if st > 0: + st -= 1 + spark_time[x, y] = st + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx == 0 and dy == 0: + continue + nx = x + dx + ny = y + dy + if wrap != 0: + if nx < 0: + nx += width + elif nx >= width: + nx -= width + if ny < 0: + ny += height + elif ny >= height: + ny -= height + else: + if nx < 0 or nx >= width or ny < 0 or ny >= height: + continue + nt = type_id[nx, ny] + if nt != 0 and type_conductivity[nt] > 0.5 and spark_time[nx, ny] == 0: + spark_time[nx, ny] = 4 + if t == id_spark: + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + continue + if st == 0: + spark_time[x, y] = -10 + elif st < 0: + spark_time[x, y] = st + 1 + + # Energy sparks + if t == id_energy: + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + if dx == 0 and dy == 0: + continue + nx = x + dx + ny = y + dy + if wrap != 0: + if nx < 0: + nx += width + elif nx >= width: + nx -= width + if ny < 0: + ny += height + elif ny >= height: + ny -= height + else: + if nx < 0 or nx >= width or ny < 0 or ny >= height: + continue + nt = type_id[nx, ny] + if nt != 0 and type_conductivity[nt] > 0.5 and spark_time[nx, ny] == 0: + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.3: + spark_time[nx, ny] = 4 + elif nt == 0 and id_spark != 0: + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.3: + type_id[nx, ny] = id_spark + temp[nx, ny] = type_temp[id_spark] + burn_time[nx, ny] = type_burn_duration[id_spark] + burning[nx, ny] = type_burning_init[id_spark] + spark_time[nx, ny] = 4 + lifetime[nx, ny] = type_lifetime[id_spark] + + # Auto-ignite by temperature + if burning[x, y] == 0 and type_burn_temp[t] > 0.0 and temp[x, y] >= type_burn_temp[t] and type_flamability[t] > 0.0: + burning[x, y] = 1 + burn_time[x, y] = type_burn_duration[t] + + # Burning + if burning[x, y] != 0: + temp[x, y] += 2.0 + burn_time[x, y] -= 1.0 + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.05: + ny = y - 1 + nx = x + if wrap != 0: + if ny < 0: + ny += height + if 0 <= ny < height: + if type_id[nx, ny] == 0 and id_smoke != 0: + type_id[nx, ny] = id_smoke + temp[nx, ny] = type_temp[id_smoke] + burn_time[nx, ny] = type_burn_duration[id_smoke] + burning[nx, ny] = type_burning_init[id_smoke] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_smoke] + + if burn_time[x, y] <= 0.0: + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + continue + + # Phase transitions + if type_evap_target[t] != 0 and temp[x, y] >= type_evap_temp[t]: + nt = type_evap_target[t] + type_id[x, y] = nt + burning[x, y] = type_burning_init[nt] + burn_time[x, y] = type_burn_duration[nt] + spark_time[x, y] = 0 + lifetime[x, y] = type_lifetime[nt] + t = nt + elif type_melt_target[t] != 0 and temp[x, y] >= type_melt_temp[t]: + nt = type_melt_target[t] + type_id[x, y] = nt + burning[x, y] = type_burning_init[nt] + burn_time[x, y] = type_burn_duration[nt] + spark_time[x, y] = 0 + lifetime[x, y] = type_lifetime[nt] + t = nt + elif type_solidify_target[t] != 0 and temp[x, y] <= type_solidify_temp[t]: + nt = type_solidify_target[t] + type_id[x, y] = nt + burning[x, y] = type_burning_init[nt] + burn_time[x, y] = type_burn_duration[nt] + spark_time[x, y] = 0 + lifetime[x, y] = type_lifetime[nt] + t = nt + elif type_freeze_target[t] != 0 and temp[x, y] <= type_freeze_temp[t]: + nt = type_freeze_target[t] + type_id[x, y] = nt + burning[x, y] = type_burning_init[nt] + burn_time[x, y] = type_burn_duration[nt] + spark_time[x, y] = 0 + lifetime[x, y] = type_lifetime[nt] + t = nt + + # Interactions (4-neighbor) + alive = True + for k in range(4): + if k == 0: + nx = x - 1 + ny = y + elif k == 1: + nx = x + 1 + ny = y + elif k == 2: + nx = x + ny = y - 1 + else: + nx = x + ny = y + 1 + + if wrap != 0: + if nx < 0: + nx += width + elif nx >= width: + nx -= width + if ny < 0: + ny += height + elif ny >= height: + ny -= height + else: + if nx < 0 or nx >= width or ny < 0 or ny >= height: + continue + + nt = type_id[nx, ny] + if nt == 0: + continue + + # Water + Sand -> Wet Sand + if t == id_water and nt == id_sand and id_wsand != 0: + type_id[nx, ny] = id_wsand + burning[nx, ny] = type_burning_init[id_wsand] + burn_time[nx, ny] = type_burn_duration[id_wsand] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_wsand] + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + alive = False + break + + # Water + Dirt -> Mud + if t == id_water and nt == id_dirt and id_mud != 0: + type_id[nx, ny] = id_mud + burning[nx, ny] = type_burning_init[id_mud] + burn_time[nx, ny] = type_burn_duration[id_mud] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_mud] + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + alive = False + break + + # Fire/Lava interactions + if t == id_lava or t == id_fire: + # Lava + Water -> Stone + Steam + if t == id_lava and nt == id_water and id_stone != 0 and id_steam != 0: + type_id[x, y] = id_stone + burning[x, y] = type_burning_init[id_stone] + burn_time[x, y] = type_burn_duration[id_stone] + spark_time[x, y] = 0 + lifetime[x, y] = type_lifetime[id_stone] + type_id[nx, ny] = id_steam + burning[nx, ny] = type_burning_init[id_steam] + burn_time[nx, ny] = type_burn_duration[id_steam] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_steam] + alive = False + break + + if nt != id_fire and nt != id_lava and type_flamability[nt] > 0.0 and id_fire != 0: + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.1: + type_id[nx, ny] = id_fire + burning[nx, ny] = type_burning_init[id_fire] + burn_time[nx, ny] = type_burn_duration[id_fire] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_fire] + + # Fire extinguishing + if t == id_fire and (nt == id_water or nt == id_wsand or nt == id_mud): + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if id_steam != 0 and (seed & np.uint32(0xFFFF)) / 65535.0 < 0.5: + type_id[nx, ny] = id_steam + burning[nx, ny] = type_burning_init[id_steam] + burn_time[nx, ny] = type_burn_duration[id_steam] + spark_time[nx, ny] = 0 + lifetime[nx, ny] = type_lifetime[id_steam] + alive = False + break + + # Acid interactions + if t == id_acid: + if nt != id_acid and nt != id_fire and nt != id_smoke and nt != id_wall and nt != id_glass: + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.05: + type_id[nx, ny] = 0 + temp[nx, ny] = ambient_temp + burn_time[nx, ny] = 0.0 + burning[nx, ny] = 0 + spark_time[nx, ny] = 0 + lifetime[nx, ny] = 0.0 + + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + if (seed & np.uint32(0xFFFF)) / 65535.0 < 0.2: + type_id[x, y] = 0 + temp[x, y] = ambient_temp + burn_time[x, y] = 0.0 + burning[x, y] = 0 + spark_time[x, y] = 0 + lifetime[x, y] = 0.0 + alive = False + break + + if not alive: + continue + + # Temperature diffusion (simplified) + cond = type_conductivity[t] + if cond <= 0.0: + cond = 0.01 + + diff_air = temp[x, y] - ambient_temp + if diff_air > 0.5 or diff_air < -0.5: + k_eff = (cond + air_conductivity) * 0.5 + transfer = diff_air * k_eff * 0.1 + if transfer > diff_air * 0.5: + transfer = diff_air * 0.5 + if transfer < diff_air * -0.5: + transfer = diff_air * -0.5 + temp[x, y] -= transfer + + # Right neighbor + nx = x + 1 + ny = y + if wrap != 0: + if nx >= width: + nx = 0 + if 0 <= nx < width: + nt = type_id[nx, ny] + if nt != 0: + ncond = type_conductivity[nt] + if ncond <= 0.0: + ncond = 0.1 + diff = temp[x, y] - temp[nx, ny] + if diff > 0.5 or diff < -0.5: + k_eff = (cond + ncond) * 0.5 + transfer = diff * k_eff * 0.1 + if transfer > diff * 0.5: + transfer = diff * 0.5 + if transfer < diff * -0.5: + transfer = diff * -0.5 + temp[x, y] -= transfer + temp[nx, ny] += transfer + + # Down neighbor + nx = x + ny = y + 1 + if wrap != 0: + if ny >= height: + ny = 0 + if 0 <= ny < height: + nt = type_id[nx, ny] + if nt != 0: + ncond = type_conductivity[nt] + if ncond <= 0.0: + ncond = 0.1 + diff = temp[x, y] - temp[nx, ny] + if diff > 0.5 or diff < -0.5: + k_eff = (cond + ncond) * 0.5 + transfer = diff * k_eff * 0.1 + if transfer > diff * 0.5: + transfer = diff * 0.5 + if transfer < diff * -0.5: + transfer = diff * -0.5 + temp[x, y] -= transfer + temp[nx, ny] += transfer + + # Movement + if type_static[t] != 0: + continue + + kind = type_kind[t] + + if kind == KIND_SOLID: + ny = y + 1 + nx = x + if wrap != 0: + if ny >= height: + ny = 0 + if 0 <= ny < height: + if type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + below = type_id[nx, ny] + if below != 0 and type_kind[below] in (KIND_LIQUID, KIND_GAS) and type_mass[t] > type_mass[below]: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 1) + continue + + if left_first: + nx = x - 1 + ny = y + 1 + if wrap != 0: + if nx < 0: + nx += width + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x + 1 + ny = y + 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + else: + nx = x + 1 + ny = y + 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x - 1 + ny = y + 1 + if wrap != 0: + if nx < 0: + nx += width + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + elif kind == KIND_LIQUID: + ny = y + 1 + nx = x + if wrap != 0: + if ny >= height: + ny = 0 + if 0 <= ny < height: + if type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + below = type_id[nx, ny] + if below != 0 and type_kind[below] == KIND_GAS and type_mass[t] > type_mass[below]: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 1) + continue + + if left_first: + nx = x - 1 + ny = y + 1 + if wrap != 0: + if nx < 0: + nx += width + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x + 1 + ny = y + 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + else: + nx = x + 1 + ny = y + 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x - 1 + ny = y + 1 + if wrap != 0: + if nx < 0: + nx += width + if ny >= height: + ny = 0 + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + # Side spread with wind bias + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + wind_bias = wind_x + if wind_bias != 0.0: + if (seed & np.uint32(0xFFFF)) / 65535.0 < min(1.0, abs(wind_bias) * 0.1): + nx = x + (1 if wind_bias > 0 else -1) + ny = y + if wrap != 0: + if nx < 0: + nx += width + elif nx >= width: + nx -= width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + if left_first: + nx = x - 1 + ny = y + if wrap != 0: + if nx < 0: + nx += width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x + 1 + ny = y + if wrap != 0: + if nx >= width: + nx = 0 + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + else: + nx = x + 1 + ny = y + if wrap != 0: + if nx >= width: + nx = 0 + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x - 1 + ny = y + if wrap != 0: + if nx < 0: + nx += width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + elif kind == KIND_GAS: + ny = y - 1 + nx = x + if wrap != 0: + if ny < 0: + ny += height + if 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + if left_first: + nx = x - 1 + ny = y - 1 + if wrap != 0: + if nx < 0: + nx += width + if ny < 0: + ny += height + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x + 1 + ny = y - 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny < 0: + ny += height + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + else: + nx = x + 1 + ny = y - 1 + if wrap != 0: + if nx >= width: + nx = 0 + if ny < 0: + ny += height + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x - 1 + ny = y - 1 + if wrap != 0: + if nx < 0: + nx += width + if ny < 0: + ny += height + if 0 <= nx < width and 0 <= ny < height and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + # Side spread with wind bias + seed = seed * np.uint32(1664525) + np.uint32(1013904223) + wind_bias = wind_x + if wind_bias != 0.0: + if (seed & np.uint32(0xFFFF)) / 65535.0 < min(1.0, abs(wind_bias) * 0.1): + nx = x + (1 if wind_bias > 0 else -1) + ny = y + if wrap != 0: + if nx < 0: + nx += width + elif nx >= width: + nx -= width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + + if left_first: + nx = x - 1 + ny = y + if wrap != 0: + if nx < 0: + nx += width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x + 1 + ny = y + if wrap != 0: + if nx >= width: + nx = 0 + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + else: + nx = x + 1 + ny = y + if wrap != 0: + if nx >= width: + nx = 0 + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + nx = x - 1 + ny = y + if wrap != 0: + if nx < 0: + nx += width + if 0 <= nx < width and type_id[nx, ny] == 0: + _fast_move(type_id, temp, burn_time, burning, spark_time, lifetime, ambient_temp, x, y, nx, ny, 0) + continue + +from ..config.settings import engine_settings, particle_properties +from .particle import Particle +from .particle_types import ParticlePropertyValue + + +class Simulation: + """The main class of the simulation.""" + + def __init__( + self, + width: int, + height: int, + x: int = 0, + y: int = 0, + particle_size: int = 3, + enable_acceleration: bool = False, + fast_mode: bool | None = None, + ) -> None: + self.width: int = width + self.height: int = height + self.x: int = x + self.y: int = y + self.particle_size: int = particle_size + + # Grid initialization + # self.particles[x][y] holds the Particle object or None + self.particles: list[list[Particle | None]] = [ + [None for _ in range(height)] for _ in range(width) + ] + + # Spatial Partitioning + # Map (cell_x, cell_y) -> set of (x, y) coordinates of active particles + self.chunk_size: int = 10 # Size of spatial chunks + self.spatial_chunks: dict[tuple[int, int], set[tuple[int, int]]] = {} + + # Active particle management + self.active_particles: set[tuple[int, int]] = set() + self.dormant_particles: set[tuple[int, int]] = set() + self.occupied_cells: set[tuple[int, int]] = set() + self.particle_count: int = 0 + + # Simulation properties + self.brush_size: int = 1 + self.max_brush_size: int = 20 + # Type alias for property values to avoid Any + self.particle_properties: dict[str, dict[str, ParticlePropertyValue]] = particle_properties # type: ignore + self.particle_types: list[str] = list(self.particle_properties.keys()) + self.current_particle_type: str = self.particle_types[0] if self.particle_types else "sand" + self.gravity: float = 9.8 + self.wind_zones: list[object] = [] + self.wind: list[float] = [0.0, 0.0] + + # Thermodynamics + self.ambient_temperature: float = 22.0 # Celsius + self.air_conductivity: float = 0.05 + + # Performance monitoring + self._tps_counter: int = 0 + self._tps_timer: float = time.time() + self._current_tps: float = 0 + self._frame_counter: int = 0 + + # Acceleration support + self._acceleration_wrapper: Any = None + + # Fast grid mode (data-oriented) + if fast_mode is None: + fast_mode = engine_settings.get("fast_sim", False) + self.fast_mode: bool = bool(fast_mode) + self.fast_use_numba: bool = NUMBA_AVAILABLE + self.fast_type_id: np.ndarray | None = None + self.fast_type_to_id: dict[str, int] = {} + self.fast_id_to_type: list[str] = [] + self.fast_type_kind: np.ndarray | None = None + self.fast_type_static: np.ndarray | None = None + self.fast_type_mass: np.ndarray | None = None + self.fast_type_conductivity: np.ndarray | None = None + self.fast_type_flamability: np.ndarray | None = None + self.fast_type_burning_init: np.ndarray | None = None + self.fast_type_burn_duration: np.ndarray | None = None + self.fast_type_burn_temp: np.ndarray | None = None + self.fast_type_temp: np.ndarray | None = None + self.fast_type_evap_target: np.ndarray | None = None + self.fast_type_evap_temp: np.ndarray | None = None + self.fast_type_melt_target: np.ndarray | None = None + self.fast_type_melt_temp: np.ndarray | None = None + self.fast_type_solidify_target: np.ndarray | None = None + self.fast_type_solidify_temp: np.ndarray | None = None + self.fast_type_freeze_target: np.ndarray | None = None + self.fast_type_freeze_temp: np.ndarray | None = None + self.fast_type_lifetime: np.ndarray | None = None + self.fast_temp: np.ndarray | None = None + self.fast_burn_time: np.ndarray | None = None + self.fast_burning: np.ndarray | None = None + self.fast_spark_time: np.ndarray | None = None + self.fast_lifetime: np.ndarray | None = None + self.fast_color_lut: np.ndarray | None = None + self.fast_id_fire: int = 0 + self.fast_id_lava: int = 0 + self.fast_id_water: int = 0 + self.fast_id_sand: int = 0 + self.fast_id_wsand: int = 0 + self.fast_id_dirt: int = 0 + self.fast_id_mud: int = 0 + self.fast_id_stone: int = 0 + self.fast_id_steam: int = 0 + self.fast_id_acid: int = 0 + self.fast_id_spark: int = 0 + self.fast_id_energy: int = 0 + self.fast_id_wall: int = 0 + self.fast_id_smoke: int = 0 + self.fast_id_glass: int = 0 + + if self.fast_mode: + self._init_fast_storage() + + @property + def acceleration_wrapper(self) -> Any: + """Get the acceleration wrapper.""" + return self._acceleration_wrapper + + @acceleration_wrapper.setter + def acceleration_wrapper(self, value: Any) -> None: + """Set the acceleration wrapper.""" + self._acceleration_wrapper = value + + def _init_fast_storage(self) -> None: + """Initialize fast, array-based storage.""" + self.fast_id_to_type = ["empty"] + self.particle_types + self.fast_type_to_id = {ptype: i + 1 for i, ptype in enumerate(self.particle_types)} + + count = len(self.fast_id_to_type) + self.fast_type_kind = np.zeros(count, dtype=np.uint8) + self.fast_type_static = np.zeros(count, dtype=np.uint8) + self.fast_type_mass = np.ones(count, dtype=np.float32) + self.fast_type_conductivity = np.zeros(count, dtype=np.float32) + self.fast_type_flamability = np.zeros(count, dtype=np.float32) + self.fast_type_burning_init = np.zeros(count, dtype=np.uint8) + self.fast_type_burn_duration = np.zeros(count, dtype=np.float32) + self.fast_type_burn_temp = np.zeros(count, dtype=np.float32) + self.fast_type_temp = np.full(count, self.ambient_temperature, dtype=np.float32) + self.fast_type_evap_target = np.zeros(count, dtype=np.int16) + self.fast_type_evap_temp = np.full(count, 9999.0, dtype=np.float32) + self.fast_type_melt_target = np.zeros(count, dtype=np.int16) + self.fast_type_melt_temp = np.full(count, 9999.0, dtype=np.float32) + self.fast_type_solidify_target = np.zeros(count, dtype=np.int16) + self.fast_type_solidify_temp = np.full(count, -9999.0, dtype=np.float32) + self.fast_type_freeze_target = np.zeros(count, dtype=np.int16) + self.fast_type_freeze_temp = np.full(count, -9999.0, dtype=np.float32) + self.fast_type_lifetime = np.zeros(count, dtype=np.float32) + self.fast_color_lut = np.zeros((count, 3), dtype=np.uint8) + + static_types = { + "wall", + "stone", + "rock", + "iron", + "gold", + "copper", + "wood", + "brass", + } + + for ptype, props in self.particle_properties.items(): + idx = self.fast_type_to_id.get(ptype) + if idx is None: + continue + + if props.get("is_gas"): + self.fast_type_kind[idx] = KIND_GAS + elif props.get("liquid"): + self.fast_type_kind[idx] = KIND_LIQUID + else: + self.fast_type_kind[idx] = KIND_SOLID + + if ptype in static_types: + self.fast_type_static[idx] = 1 + + mass_val = props.get("mass", 1.0) + try: + self.fast_type_mass[idx] = float(mass_val) + except (TypeError, ValueError): + self.fast_type_mass[idx] = 1.0 + + try: + self.fast_type_conductivity[idx] = float(props.get("conductivity", 0.0)) + except (TypeError, ValueError): + self.fast_type_conductivity[idx] = 0.0 + + try: + self.fast_type_flamability[idx] = float(props.get("flamability", 0.0)) + except (TypeError, ValueError): + self.fast_type_flamability[idx] = 0.0 + + self.fast_type_burning_init[idx] = 1 if props.get("burning", False) else 0 + + try: + self.fast_type_burn_duration[idx] = float(props.get("burn_duration", 0.0)) + except (TypeError, ValueError): + self.fast_type_burn_duration[idx] = 0.0 + + try: + self.fast_type_burn_temp[idx] = float(props.get("burn_temperature", 0.0)) + except (TypeError, ValueError): + self.fast_type_burn_temp[idx] = 0.0 + + try: + self.fast_type_temp[idx] = float(props.get("temperature", self.ambient_temperature)) + except (TypeError, ValueError): + self.fast_type_temp[idx] = float(self.ambient_temperature) + + lifetime_val = props.get("lifetime") + if lifetime_val is not None: + try: + self.fast_type_lifetime[idx] = float(lifetime_val) + except (TypeError, ValueError): + self.fast_type_lifetime[idx] = 0.0 + + evap = props.get("evaporate") + if isinstance(evap, str): + tid = self.fast_type_to_id.get(evap) + if tid: + self.fast_type_evap_target[idx] = tid + evap_temp = props.get("evaporate_temperature") + if evap_temp is not None: + try: + self.fast_type_evap_temp[idx] = float(evap_temp) + except (TypeError, ValueError): + pass + + melt = props.get("melt") + if isinstance(melt, str): + tid = self.fast_type_to_id.get(melt) + if tid: + self.fast_type_melt_target[idx] = tid + melt_temp = props.get("melt_temperature") + if melt_temp is not None: + try: + self.fast_type_melt_temp[idx] = float(melt_temp) + except (TypeError, ValueError): + pass + + solidify = props.get("solidify") + if isinstance(solidify, str): + tid = self.fast_type_to_id.get(solidify) + if tid: + self.fast_type_solidify_target[idx] = tid + solidify_temp = props.get("solidify_temperature") + if solidify_temp is not None: + try: + self.fast_type_solidify_temp[idx] = float(solidify_temp) + except (TypeError, ValueError): + pass + + freeze = props.get("freeze") + if isinstance(freeze, str): + tid = self.fast_type_to_id.get(freeze) + if tid: + self.fast_type_freeze_target[idx] = tid + freeze_temp = props.get("freeze_temperature") + if freeze_temp is not None: + try: + self.fast_type_freeze_temp[idx] = float(freeze_temp) + except (TypeError, ValueError): + pass + + color = props.get("color", (255, 255, 255)) + if isinstance(color, (list, tuple)) and len(color) >= 3: + self.fast_color_lut[idx, 0] = int(color[0]) + self.fast_color_lut[idx, 1] = int(color[1]) + self.fast_color_lut[idx, 2] = int(color[2]) + else: + self.fast_color_lut[idx] = (255, 255, 255) + + self.fast_type_id = np.zeros((self.width, self.height), dtype=np.int16) + self.fast_temp = np.full((self.width, self.height), self.ambient_temperature, dtype=np.float32) + self.fast_burn_time = np.zeros((self.width, self.height), dtype=np.float32) + self.fast_burning = np.zeros((self.width, self.height), dtype=np.uint8) + self.fast_spark_time = np.zeros((self.width, self.height), dtype=np.int16) + self.fast_lifetime = np.zeros((self.width, self.height), dtype=np.float32) + + self.fast_id_fire = self.fast_type_to_id.get("fire", 0) + self.fast_id_lava = self.fast_type_to_id.get("lava", 0) + self.fast_id_water = self.fast_type_to_id.get("water", 0) + self.fast_id_sand = self.fast_type_to_id.get("sand", 0) + self.fast_id_wsand = self.fast_type_to_id.get("wsand", 0) + self.fast_id_dirt = self.fast_type_to_id.get("dirt", 0) + self.fast_id_mud = self.fast_type_to_id.get("mud", 0) + self.fast_id_stone = self.fast_type_to_id.get("stone", 0) + self.fast_id_steam = self.fast_type_to_id.get("steam", 0) + self.fast_id_acid = self.fast_type_to_id.get("acid", 0) + self.fast_id_spark = self.fast_type_to_id.get("spark", 0) + self.fast_id_energy = self.fast_type_to_id.get("energy", 0) + self.fast_id_wall = self.fast_type_to_id.get("wall", 0) + self.fast_id_smoke = self.fast_type_to_id.get("smoke", 0) + self.fast_id_glass = self.fast_type_to_id.get("glass", 0) + + def reset_particle_count(self) -> None: + """Recalculate particle count.""" + if self.fast_mode and self.fast_type_id is not None: + self.particle_count = int(np.count_nonzero(self.fast_type_id)) + return + count = 0 + for x in range(self.width): + for y in range(self.height): + if self.particles[x][y] is not None: + count += 1 + self.particle_count = count + + def get_accurate_particle_count(self) -> int: + if self.fast_mode and self.fast_type_id is not None: + return int(np.count_nonzero(self.fast_type_id)) + return self.particle_count + + def track_tps(self) -> float: + """Track Ticks Per Second.""" + self._tps_counter += 1 + current_time = time.time() + elapsed = current_time - self._tps_timer + if elapsed >= 1.0: + self._current_tps = self._tps_counter / elapsed + self._tps_counter = 0 + self._tps_timer = current_time + return self._current_tps + + # --- Spatial Hashing --- + def _add_to_spatial_grid(self, x: int, y: int): + cx, cy = x // self.chunk_size, y // self.chunk_size + if (cx, cy) not in self.spatial_chunks: + self.spatial_chunks[(cx, cy)] = set() + self.spatial_chunks[(cx, cy)].add((x, y)) + + def _remove_from_spatial_grid(self, x: int, y: int): + cx, cy = x // self.chunk_size, y // self.chunk_size + if (cx, cy) in self.spatial_chunks: + self.spatial_chunks[(cx, cy)].discard((x, y)) + if not self.spatial_chunks[(cx, cy)]: + del self.spatial_chunks[(cx, cy)] + + def _get_neighbors(self, x: int, y: int, radius: int = 1) -> list[tuple[int, int]]: + """Get neighboring particles efficiently.""" + neighbors: list[tuple[int, int]] = [] + cx_start = (x - radius) // self.chunk_size + cx_end = (x + radius) // self.chunk_size + cy_start = (y - radius) // self.chunk_size + cy_end = (y + radius) // self.chunk_size + + for cx in range(cx_start, cx_end + 1): + for cy in range(cy_start, cy_end + 1): + if (cx, cy) in self.spatial_chunks: + for px, py in self.spatial_chunks[(cx, cy)]: + if abs(px - x) <= radius and abs(py - y) <= radius and (px != x or py != y): + neighbors.append((px, py)) + return neighbors + + def _wake_area(self, x: int, y: int, radius: int = 1): + """Wake up inactive particles in the area.""" + for dx in range(-radius, radius + 1): + for dy in range(-radius, radius + 1): + nx, ny = x + dx, y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + particle = self.particles[nx][ny] + if particle and (nx, ny) not in self.active_particles: + # Only wake if it's movable or reactive + if particle.particle_type not in ["wall", "stone", "rock"]: + self.active_particles.add((nx, ny)) + + # --- Particle Creation/Destruction --- + + def create_particle(self, x: int, y: int, particle_type: str | None = None) -> None: + if particle_type is None: + particle_type = self.current_particle_type.lower() + else: + particle_type = particle_type.lower() + + if particle_type not in self.particle_properties: + return + + grid_x = x // self.particle_size + grid_y = y // self.particle_size + + if not (0 <= grid_x < self.width and 0 <= grid_y < self.height): + return + + if self.fast_mode and self.fast_type_id is not None: + if self.fast_type_id[grid_x, grid_y] != 0: + if ( + particle_type == "spark" + and self.fast_type_conductivity is not None + and self.fast_spark_time is not None + ): + tid = int(self.fast_type_id[grid_x, grid_y]) + if tid != 0 and self.fast_type_conductivity[tid] > 0.5 and self.fast_spark_time[grid_x, grid_y] == 0: + self.fast_spark_time[grid_x, grid_y] = 4 + return + tid = self.fast_type_to_id.get(particle_type) + if not tid: + return + self.fast_type_id[grid_x, grid_y] = tid + if self.fast_temp is not None and self.fast_type_temp is not None: + self.fast_temp[grid_x, grid_y] = self.fast_type_temp[tid] + if self.fast_burn_time is not None and self.fast_type_burn_duration is not None: + self.fast_burn_time[grid_x, grid_y] = self.fast_type_burn_duration[tid] + if self.fast_burning is not None and self.fast_type_burning_init is not None: + self.fast_burning[grid_x, grid_y] = self.fast_type_burning_init[tid] + if self.fast_spark_time is not None: + self.fast_spark_time[grid_x, grid_y] = 0 + if self.fast_lifetime is not None and self.fast_type_lifetime is not None: + self.fast_lifetime[grid_x, grid_y] = self.fast_type_lifetime[tid] + self.particle_count += 1 + return + + if self.particles[grid_x][grid_y] is not None: + if particle_type == "spark": + target = self.particles[grid_x][grid_y] + if target and target.conductivity > 0.5 and target.spark_time == 0: + target.spark_time = 4 + self.active_particles.add((grid_x, grid_y)) + return + + properties = self.particle_properties[particle_type] + new_particle = Particle.from_type(self, (grid_x, grid_y), particle_type, properties) + + self.particles[grid_x][grid_y] = new_particle + self.occupied_cells.add((grid_x, grid_y)) + if particle_type not in ["wall", "stone", "rock"]: + self.active_particles.add((grid_x, grid_y)) + self._add_to_spatial_grid(grid_x, grid_y) + + self.particle_count += 1 + self._wake_area(grid_x, grid_y) + + def create_particle_circle(self, center_x: int, center_y: int): + brush_size = int(self.brush_size) + step = self.particle_size + for dx in range(-brush_size, brush_size + 1): + for dy in range(-brush_size, brush_size + 1): + if dx * dx + dy * dy <= brush_size * brush_size: + self.create_particle( + center_x + dx * step, + center_y + dy * step + ) + + def clear_particles_circle(self, center_x: int, center_y: int): + brush_size = int(self.brush_size) + step = self.particle_size + for dx in range(-brush_size, brush_size + 1): + for dy in range(-brush_size, brush_size + 1): + if dx * dx + dy * dy <= brush_size * brush_size: + grid_x = (center_x + dx * step) // step + grid_y = (center_y + dy * step) // step + + if 0 <= grid_x < self.width and 0 <= grid_y < self.height: + if self.fast_mode and self.fast_type_id is not None: + if self.fast_type_id[grid_x, grid_y] != 0: + self.fast_type_id[grid_x, grid_y] = 0 + if self.fast_temp is not None: + self.fast_temp[grid_x, grid_y] = self.ambient_temperature + if self.fast_burn_time is not None: + self.fast_burn_time[grid_x, grid_y] = 0.0 + if self.fast_burning is not None: + self.fast_burning[grid_x, grid_y] = 0 + if self.fast_spark_time is not None: + self.fast_spark_time[grid_x, grid_y] = 0 + if self.fast_lifetime is not None: + self.fast_lifetime[grid_x, grid_y] = 0.0 + self.particle_count = max(0, self.particle_count - 1) + continue + if self.particles[grid_x][grid_y]: + self.particles[grid_x][grid_y] = None + self.active_particles.discard((grid_x, grid_y)) + self.occupied_cells.discard((grid_x, grid_y)) + self._remove_from_spatial_grid(grid_x, grid_y) + self.particle_count = max(0, self.particle_count - 1) + self._wake_area(grid_x, grid_y) # Wake neighbors so they fall into the hole + + def transform_particle(self, x: int, y: int, new_type: str) -> bool: + """Transform particle at x,y to new_type. Returns True if successful.""" + if new_type not in self.particle_properties: + return False + + if self.fast_mode and self.fast_type_id is not None: + tid = self.fast_type_to_id.get(new_type) + if not tid: + return False + self.fast_type_id[x, y] = tid + if self.fast_burning is not None and self.fast_type_burning_init is not None: + self.fast_burning[x, y] = self.fast_type_burning_init[tid] + if self.fast_burn_time is not None and self.fast_type_burn_duration is not None: + self.fast_burn_time[x, y] = self.fast_type_burn_duration[tid] + if self.fast_spark_time is not None: + self.fast_spark_time[x, y] = 0 + if self.fast_lifetime is not None and self.fast_type_lifetime is not None: + self.fast_lifetime[x, y] = self.fast_type_lifetime[tid] + return True + + old_particle = self.particles[x][y] + if not old_particle: + return False + + properties = self.particle_properties[new_type] + # Preserve some properties? For now, clean init + new_particle = Particle.from_type(self, (x, y), new_type, properties) + new_particle.temperature = old_particle.temperature # Preserve temp + new_particle.velocity = old_particle.velocity # Preserve momentum + + self.particles[x][y] = new_particle + if new_type not in ["wall"]: + self.active_particles.add((x, y)) + self._add_to_spatial_grid(x, y) + + return True + + # --- Physics & Interactions --- + + def handle_phase_transitions(self, particle: Particle, x: int, y: int) -> bool: + """Check transitions. Return True if particle changed type (pointer invalid).""" + # Evaporate + if particle.evaporate and particle.temperature >= (particle.evaporate_temperature or 9999): + return self.transform_particle(x, y, particle.evaporate) + + # Melt + if particle.melt and particle.temperature >= (particle.melt_temperature or 9999): + return self.transform_particle(x, y, particle.melt) + + # Freeze/Solidify + if particle.solidify and particle.temperature <= (particle.solidify_temperature or -9999): + return self.transform_particle(x, y, particle.solidify) + + if particle.freeze and particle.temperature <= (particle.freeze_temperature or -9999): + return self.transform_particle(x, y, particle.freeze) + + return False + + def handle_interactions(self, particle: Particle, x: int, y: int) -> bool: + """Simple adjacent interactions. Returns True if this particle died/changed.""" + # 4-neighbor check + neighbors = [(x+dx, y+dy) for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]] + + + ptype = particle.particle_type + + for nx, ny in neighbors: + if not (0 <= nx < self.width and 0 <= ny < self.height): + continue + + neighbor = self.particles[nx][ny] + if not neighbor: + continue + + ntype = neighbor.particle_type + + # Water + Sand -> Wet Sand + + if (ptype == "water" and ntype == "sand"): + _ = self.transform_particle(nx, ny, "wsand") + # Remove water + self.particles[x][y] = None + self.active_particles.discard((x,y)) + self.occupied_cells.discard((x,y)) + self._remove_from_spatial_grid(x, y) + self.particle_count -= 1 + return True # This particle is gone + + # Water + Dirt -> Mud + + if (ptype == "water" and ntype == "dirt"): + _ = self.transform_particle(nx, ny, "mud") + # Remove water + self.particles[x][y] = None + self.active_particles.discard((x,y)) + self.occupied_cells.discard((x,y)) + self._remove_from_spatial_grid(x, y) + self.particle_count -= 1 + return True # This particle is gone + + # Fire/Lava interactions + if ptype in ["fire", "lava"]: + # Lava + Water -> Stone + Steam + if ntype == "water": + if ptype == "lava": + # Lava cools down to stone, water boils to steam + _ = self.transform_particle(x, y, "stone") + _ = self.transform_particle(nx, ny, "steam") + return True + + if neighbor.flamability > 0 and ntype != "fire" and ntype != "lava": + if random.random() < 0.1: # Chance to ignite + _ = self.transform_particle(nx, ny, "fire") + + # Fire Extinguishing + if ptype == "fire" and ntype in ["water", "wsand", "mud"]: + self.particles[x][y] = None # Fire dies + self._remove_from_spatial_grid(x, y) + self.occupied_cells.discard((x, y)) + self.particle_count -= 1 + + # Water boils partial chance + if random.random() < 0.5: + _ = self.transform_particle(nx, ny, "steam") + return True # Fire died + + # Acid interactions + if ptype == "acid": + if ntype not in ["acid", "glass", "wall", "fire", "smoke"]: + # Dissolve neighbor + if random.random() < 0.05: # Slow eat + # Destroy neighbor + self.particles[nx][ny] = None + self.active_particles.discard((nx, ny)) + self.occupied_cells.discard((nx, ny)) + self._remove_from_spatial_grid(nx, ny) + self.particle_count -= 1 + + # Weak chance to use up acid + if random.random() < 0.2: + self.particles[x][y] = None + self.occupied_cells.discard((x, y)) + self._remove_from_spatial_grid(x, y) + self.particle_count -= 1 + return True + + self._wake_area(x, y) + self.active_particles.add((x, y)) # Acid stays and falls + return False # Acid didn't die yet + + return False + + def update_temperature_diffusion(self, particle: Particle, x: int, y: int) -> bool: + """Heat transfer using conductivity and simple diffusion.""" + # Transfer with ambient air and neighbors + + # Neighbors (including self for simplicity in loop, but we skip self) + # Actually proper standard 4-way + neighbors = [(x+dx, y+dy) for dx, dy in [(-1,0), (1,0), (0,-1), (0,1)]] + + # If conductivity is 0, it's a perfect insulator (e.g. some special wall?), skip + # Default conductivity is 0 in Particle.__init__... we might need to assume a min value if not set? + # Let's use particle.conductivity directly. + + p_cond = particle.conductivity if particle.conductivity > 0 else 0.01 # Fallback + + total_transfer = 0.0 + + for nx, ny in neighbors: + if not (0 <= nx < self.width and 0 <= ny < self.height): + continue + + neighbor = self.particles[nx][ny] + + if neighbor: + # Particle-to-Particle transfer + n_cond = neighbor.conductivity if neighbor.conductivity > 0 else 0.1 + + # Effective conductivity (series) + # k_eff = 2 * p * n / (p + n) # physics formula for interface + # Simplified: average + k_eff = (p_cond + n_cond) * 0.5 + + diff = particle.temperature - neighbor.temperature + if abs(diff) > 0.5: + # Rate depends on difference and conductivity + transfer_amt = diff * k_eff * 0.1 # 0.1 is time step/factor + + # Clamp transfer to not overshoot (simple stability) + if abs(transfer_amt) > abs(diff) * 0.5: + transfer_amt = diff * 0.5 + + particle.temperature -= transfer_amt + neighbor.temperature += transfer_amt + total_transfer += abs(transfer_amt) + + # Wake neighbor if it got hot/cold + if abs(transfer_amt) > 0.5: + self.active_particles.add((nx, ny)) + else: + # Particle-to-Air (Ambient) transfer + # Air has constant temp and conductivity + diff = particle.temperature - self.ambient_temperature + if abs(diff) > 0.5: + k_eff = (p_cond + self.air_conductivity) * 0.5 + transfer_amt = diff * k_eff * 0.1 + + if abs(transfer_amt) > abs(diff) * 0.5: + transfer_amt = diff * 0.5 + + particle.temperature -= transfer_amt + total_transfer += abs(transfer_amt) + + # If significant heat happened, keep self awake + return total_transfer > 0.5 + + + # --- Main Step --- + + def simulate_step(self, dt: float) -> None: + """Main update loop.""" + if self.fast_mode and self.fast_type_id is not None: + self._simulate_step_fast(dt) + return + wrapper = self._acceleration_wrapper + if wrapper and getattr(wrapper, "enable_acceleration", False): + wrapper.apply_physics_accelerated(dt) + return + + self._simulate_step_python(dt) + + def _simulate_step_fast(self, dt: float) -> None: + """Fast, array-based simulation step.""" + if ( + self.fast_type_id is None + or self.fast_temp is None + or self.fast_burn_time is None + or self.fast_burning is None + or self.fast_spark_time is None + or self.fast_lifetime is None + or self.fast_type_kind is None + or self.fast_type_static is None + or self.fast_type_mass is None + or self.fast_type_conductivity is None + or self.fast_type_flamability is None + or self.fast_type_burning_init is None + or self.fast_type_burn_duration is None + or self.fast_type_burn_temp is None + or self.fast_type_temp is None + or self.fast_type_evap_target is None + or self.fast_type_evap_temp is None + or self.fast_type_melt_target is None + or self.fast_type_melt_temp is None + or self.fast_type_solidify_target is None + or self.fast_type_solidify_temp is None + or self.fast_type_freeze_target is None + or self.fast_type_freeze_temp is None + or self.fast_type_lifetime is None + ): + return + self._frame_counter += 1 + _fast_step_numba( + self.fast_type_id, + self.fast_temp, + self.fast_burn_time, + self.fast_burning, + self.fast_spark_time, + self.fast_lifetime, + self.fast_type_kind, + self.fast_type_static, + self.fast_type_mass, + self.fast_type_conductivity, + self.fast_type_flamability, + self.fast_type_burning_init, + self.fast_type_burn_duration, + self.fast_type_burn_temp, + self.fast_type_temp, + self.fast_type_evap_target, + self.fast_type_evap_temp, + self.fast_type_melt_target, + self.fast_type_melt_temp, + self.fast_type_solidify_target, + self.fast_type_solidify_temp, + self.fast_type_freeze_target, + self.fast_type_freeze_temp, + self.fast_type_lifetime, + self.width, + self.height, + self._frame_counter, + dt, + 1 if engine_settings.get("wrap_particles", False) else 0, + float(self.wind[0]), + float(self.wind[1]), + float(self.ambient_temperature), + float(self.air_conductivity), + int(self.fast_id_fire), + int(self.fast_id_lava), + int(self.fast_id_water), + int(self.fast_id_sand), + int(self.fast_id_wsand), + int(self.fast_id_dirt), + int(self.fast_id_mud), + int(self.fast_id_stone), + int(self.fast_id_steam), + int(self.fast_id_acid), + int(self.fast_id_spark), + int(self.fast_id_energy), + int(self.fast_id_wall), + int(self.fast_id_smoke), + int(self.fast_id_glass), + ) + if self._frame_counter % 10 == 0: + self.particle_count = int(np.count_nonzero(self.fast_type_id)) + + def _simulate_step_python(self, dt: float) -> None: + """Original Python physics implementation.""" + next_active_particles: set[tuple[int, int]] = set() + height = self.height + rrandom = random.random + rshuffle = random.shuffle + self._frame_counter += 1 + + # Bucket active particles by row to avoid per-frame sort + active_by_row: list[list[tuple[int, int]]] = [[] for _ in range(height)] + for x, y in self.active_particles: + if 0 <= y < height: + active_by_row[y].append((x, y)) + + for y in range(height - 1, -1, -1): + row = active_by_row[y] + if not row: + continue + for x, y in row: + particle = self.particles[x][y] + if not particle: + continue + keep_active = False + + # --- Special Logic: Energy & Spark --- + if particle.particle_type == "energy": + # High conductivity and energy transfer + # Moves in straight line, penetrates non-solids + # logic handled in collision skip below + + # Check for nearby conductors to spawn sparks + for nx, ny in self._get_neighbors(x, y, radius=1): + neighbor = self.particles[nx][ny] + if neighbor and neighbor.particle_type != "energy": + if neighbor.conductivity > 0.5 or neighbor.particle_type in ["iron", "gold", "copper"]: + if rrandom() < 0.3: + # Spawn spark + self.create_particle(nx * self.particle_size, ny * self.particle_size, "spark") + + # --- Handle Spark logic (State based) --- + if particle.spark_time > 0: + keep_active = True + particle.spark_time -= 1 + # Spread to neighbors + # Standard TPT spread is to all contact neighbors + for dx, dy in [(-1,0), (1,0), (0,-1), (0,1), (-1,-1), (1,1), (-1,1), (1,-1)]: + nx, ny = x + dx, y + dy + if 0 <= nx < self.width and 0 <= ny < self.height: + neighbor = self.particles[nx][ny] + if neighbor and neighbor.conductivity > 0.5 and neighbor.spark_time == 0: + neighbor.spark_time = 4 + next_active_particles.add((nx, ny)) + + # If the particle itself is a "spark" type particle (the injector), + # we can make it disappear or transform. + if particle.particle_type == "spark": + particle.spark_time = 0 + self.particles[x][y] = None + self.occupied_cells.discard((x, y)) + self._remove_from_spatial_grid(x, y) + self.particle_count -= 1 + continue + + # Cooldown after spark + if particle.spark_time == 0: + particle.spark_time = -10 # Cooldown + + elif particle.spark_time < 0: + particle.spark_time += 1 + + # --- 0. Lifetime --- + if particle.lifetime is not None: + particle.lifetime -= dt * 60 # Convert to frames assuming 60fps base for json values + if particle.lifetime <= 0: + # Death + self.particles[x][y] = None + self._remove_from_spatial_grid(x, y) + self.occupied_cells.discard((x, y)) + self.particle_count -= 1 + + # Spawn produces_on_death + # Check if property exists in original props + props = self.particle_properties.get(particle.particle_type, {}) + spawn_type = props.get("produces_on_death") + if spawn_type and isinstance(spawn_type, str): + # Create producer at this spot + self.create_particle(x * self.particle_size, y * self.particle_size, spawn_type) + + self._wake_area(x, y) + continue + + # --- 1. Life & State Checks --- + if particle.burning: + keep_active = True + particle.temperature += 2 + particle.burn_time -= 1 + + # Emit smoke + if rrandom() < 0.05: # 5% chance + if y - 1 >= 0 and self.particles[x][y-1] is None: + # Create smoke + self.create_particle(x, y-1, "smoke") + # Hacky: change the types since create_particle reads from current_particle_type global + p = self.particles[x][y-1] + if p: + p.particle_type = "smoke" + p.is_gas = True + p.mass = 0.1 + p.color = [100, 100, 100] + + if particle.burn_time <= 0: + self.particles[x][y] = None + self._remove_from_spatial_grid(x, y) + self.occupied_cells.discard((x, y)) + self.particle_count -= 1 + self._wake_area(x, y) + continue + + if self.handle_phase_transitions(particle, x, y): + # Particle changed type, might need re-processing next frame + next_active_particles.add((x, y)) + continue + + if self.handle_interactions(particle, x, y): + continue + + if self.update_temperature_diffusion(particle, x, y): + keep_active = True + + # --- 2. Movement Logic (Velocity Based) --- + moved = False + + # Apply Gravity / Forces + if particle.particle_type not in ["wall", "wood", "stone", "iron", "gold", "copper"]: + + # Gravity + if not particle.is_gas: + particle.velocity[1] += self.gravity * dt + else: + particle.velocity[1] -= (self.gravity * 0.5) * dt # Buoyancy + + # Wind / Air Resistance + # Simple drag: 0.99 + particle.velocity[0] *= 0.99 + particle.velocity[1] *= 0.99 + + # Apply Global Wind (only affects light particles more?) + if self.wind[0] != 0 or self.wind[1] != 0: + # Mass effect? Light particles affected more + effect = 1.0 / particle.mass + particle.velocity[0] += self.wind[0] * effect * dt + particle.velocity[1] += self.wind[1] * effect * dt + + # Clamp Velocity + particle.velocity[0] = max(min(particle.velocity[0], 8), -8) + particle.velocity[1] = max(min(particle.velocity[1], 8), -8) + + # Calculate theoretical new position + vx, vy = particle.velocity + + # If velocity is very small, use simple logic to avoid float jitter execution + if abs(vx) < 0.01 and abs(vy) < 0.01: + # Still try to fall if gravity applies? + if particle.particle_type not in ["wall", "wood", "stone", "iron", "gold", "copper"] and not particle.is_gas: + vy = 0.5 # Force smaller drop check if simply resting + + # Bresenham-like stepping + # Only step if significant velocity + steps = int(max(abs(vx), abs(vy))) + if steps == 0: + # Try moving at least 1 pixel if there is "intent" (gravity/buoyancy) + # e.g., v=0.5, steps=0. But we should try to move 1 px probabilistically or just check 1 px? + # For stability, check 1 pixel immediate neighbors if v != 0 + if abs(vx) > 0 or abs(vy) > 0: + steps = 1 + + current_x, current_y = x, y + final_x, final_y = x, y + + if steps > 0: + dx = vx / steps + dy = vy / steps + + for _ in range(steps): + next_x = int(current_x + dx) + next_y = int(current_y + dy) + + # Boundary Check + if not (0 <= next_x < self.width and 0 <= next_y < self.height): + from ..config.settings import engine_settings + if engine_settings.get("wrap_particles", False): + # Wrap around + next_x %= self.width + next_y %= self.height + else: + # Delete if off screen (sides or bottom) + self.particles[x][y] = None + self.active_particles.discard((x, y)) + self.occupied_cells.discard((x, y)) + self._remove_from_spatial_grid(x, y) + self.particle_count = max(0, self.particle_count - 1) + particle.velocity = [0.0, 0.0] + break + + if next_x == final_x and next_y == final_y: + # Still in same cell (sub-pixel move), accumulate + current_x += dx + current_y += dy + continue + + # Collision Check + target = self.particles[next_x][next_y] + + if target is None: + # Empty, move there + final_x, final_y = next_x, next_y + current_x += dx + current_y += dy + else: + # Hit something. + # Density Swap Check + # If I am heavier than target liquid/gas, swap + # e.g. Sand (solid) vs Water (liquid) + + my_density = particle.mass # Simplified density + target_density = target.mass + + swap = False + if particle.solid and (target.liquid or target.is_gas): + swap = True + elif particle.liquid and target.is_gas: + swap = True + elif particle.liquid and target.liquid and my_density > target_density: + # Heavy liquid sinks in light liquid (Oil vs Water) + swap = True + + if swap: + # Swap positions? + # Actually difficult in single-pass. + # We'll just define target as valid, and handle swap in execution + final_x, final_y = next_x, next_y + + # But we stop stepping if we swap, usually, due to friction/viscosity + particle.velocity[0] *= 0.5 + particle.velocity[1] *= 0.5 + break + elif particle.particle_type == "energy" and not target.particle_type == "wall": + # Energy passes through everything except Walls + final_x, final_y = next_x, next_y + current_x += dx + current_y += dy + continue + else: + # Hard collision + particle.velocity = [0.0, 0.0] + # Try to slide? (Stop vertical, allow horizontal?) + break + + # Special logic for liquids/sand that "settle" if straight fall failed + # If we didn't move + if final_x == x and final_y == y: + if particle.particle_type in ["sand", "dirt", "snow", "mud", "wsand", "rock"]: + # Try simple diagonal slide if blocked below + if y + 1 < self.height: + dirs = [-1, 1] + rshuffle(dirs) + for dx in dirs: + nx = x + dx + if 0 <= nx < self.width and self.particles[nx][y+1] is None: + # Slide + final_x, final_y = nx, y+1 + break + elif particle.liquid: + # Liquid spread + dirs = [-1, 1] + rshuffle(dirs) + if y + 1 < self.height: + # Try diagonal + moved_diag = False + for dx in dirs: + nx = x + dx + if 0 <= nx < self.width and self.particles[nx][y+1] is None: + final_x, final_y = nx, y+1 + moved_diag = True + break + if not moved_diag: + # Try side + for dx in dirs: + nx = x + dx + if 0 <= nx < self.width and self.particles[nx][y] is None: + final_x, final_y = nx, y + break + elif particle.is_gas: + # Gas spread + dirs = [-1, 1] + rshuffle(dirs) + # Up diag + moved_diag = False + if y - 1 >= 0: + for dx in dirs: + nx = x + dx + if 0 <= nx < self.width and self.particles[nx][y-1] is None: + final_x, final_y = nx, y-1 + moved_diag = True + break + if not moved_diag: + # Side + for dx in dirs: + nx = x + dx + if 0 <= nx < self.width and self.particles[nx][y] is None: + final_x, final_y = nx, y + break + + # Execute Move + if final_x != x or final_y != y: + tx, ty = final_x, final_y + target_p = self.particles[tx][ty] + + # If target occupied (Swap case) + if target_p: + # Move target to my old pos + self.particles[x][y] = target_p + target_p.position = (x, y) + self.active_particles.add((x, y)) # Wake swapped particle + # Don't remove from occupied/spatial, just swapping pointers in grid is enough usually? + # No, spatial grid needs update since (x,y) set changed? + # Wait, spatial grid is Set[(x,y)]. If (x,y) still occupied, no change needed in spatial grid keys, + # just who is there. + # But we track particles sets in chunks. (x,y) -> chunk. + # If (x,y) is still occupied, chunk set is fine. + pass + else: + # Empty target + self.particles[x][y] = None + self._remove_from_spatial_grid(x, y) + self.occupied_cells.discard((x, y)) + + self.particles[tx][ty] = particle + particle.position = (tx, ty) + self.occupied_cells.add((tx, ty)) + self._add_to_spatial_grid(tx, ty) + + self._wake_area(x, y) + self._wake_area(tx, ty) + + next_active_particles.add((tx, ty)) + moved = True + + # --- 3. Dormancy Check --- + # If it moved, it is active in next frame. + # If it didn't move, we don't add it to next list, effectively sleeping it. + # But we must ensure it stays awake if it changed state or temperature logic above kept it awake? + + if not moved: + if keep_active: + next_active_particles.add((x, y)) + + # Random Tick: Wake up random occupied cells to check for updates (interactions, falling) + # 0.5% of total particles every 4 frames + if self.occupied_cells and (self._frame_counter % 4 == 0): + occupied_list = list(self.occupied_cells) + num_ticks = max(1, len(occupied_list) // 200) + if num_ticks >= len(occupied_list): + for pos in occupied_list: + next_active_particles.add(pos) + else: + for pos in random.sample(occupied_list, num_ticks): + next_active_particles.add(pos) + + self.active_particles = next_active_particles diff --git a/src/ui/__init__.py b/python/src/rendering/__init__.py similarity index 100% rename from src/ui/__init__.py rename to python/src/rendering/__init__.py diff --git a/src/physics/py.typed b/python/src/rendering/py.typed similarity index 100% rename from src/physics/py.typed rename to python/src/rendering/py.typed diff --git a/python/src/rendering/rendering.py b/python/src/rendering/rendering.py new file mode 100644 index 0000000..d4671da --- /dev/null +++ b/python/src/rendering/rendering.py @@ -0,0 +1,913 @@ +from __future__ import annotations +""" +#File Name: rendering.py +Rendering class for the particle simulation. + +This class is responsible for rendering the particles, UI elements, and debug +information on the screen. +It handles the setup of the display, pre-rendering +of static UI elements, +and the drawing of particles, buttons, and debug overlays. + +The `draw_particles` function is the main method for rendering the particles +on the screen. It takes the particle data, active particles, particle size, +and particle colors as input, +and renders the particles on the `particle_surface`. The `particle_surface` is +then blitted onto the main screen. + +The `draw_zoom_window` function is used to render a zoomed-in view of the +particles around the mouse cursor. +It creates a separate surface for the zoomed-in view and returns it. + +The `draw_debug_overlay` responsible for rendering the debug information, +such as FPS, mouse position, and particle information, on the screen. + +The `draw_buttons` function handles the rendering of the category buttons, +particle buttons, and other UI elements +like the clear screen and settings buttons. + +The `render_brush_cursor` draw a visual indicator for the current brush size. + +The `draw_brush_size_slider` renders a slider for adjusting the brush size. + +The `draw_settings_menu` settings menu that can be displayed on the screen. + +The `clear_screen` reset the simulation grid and clear the display surfaces. +The `setup_gpu_rendering` placeholder for GPU-based rendering setup. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import pygame as pg_typing + from ..physics.particle import Particle + from ..physics.sim import Simulation + +import pygame +import random +import numpy as np + +from ..config.settings import ( + engine_settings, + particle_properties, +) + + +class Rendering: + """Main rendering system""" + + def __init__(self, width: int, height: int) -> None: + # self.setup_gpu_rendering() + self.screen = pygame.display.set_mode((width, height)) + self.background = pygame.Surface((width, height)) + self.background.fill((0, 0, 0)) + self.width = width + self.height = height + self.particle_count = 0 + self.particle_colors: dict[str, tuple[int, int, int] | list[int]] = {} + self.particle_properties = particle_properties + self.particle_surface = pygame.Surface( + (width, height), pygame.SRCALPHA + ) + self.zoom_window_surface = pygame.Surface((200, 200), pygame.SRCALPHA) + # self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + self.settings_menu_surface = pygame.Surface( + (300, 300), pygame.SRCALPHA + ) + self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + self._last_debug_info: tuple[int, int, int, int, int] | None = None + self.buttons: dict[str, pygame.Rect] = {} + self.button_height = 30 + self.button_width = 100 + self.clear_screen_button = pygame.Rect(10, 10, 100, 30) + self.settings_button = pygame.Rect(10, 50, 100, 30) + self.cached_fonts = { + "debug": pygame.font.SysFont(None, 24), + "button": pygame.font.SysFont(None, 20), + "slider": pygame.font.SysFont(None, 20), + "settings": pygame.font.SysFont(None, 20), + "zoom": pygame.font.SysFont(None, 20), + "brush_size": pygame.font.SysFont(None, 20), + "zoom_window": pygame.font.SysFont(None, 20), + "zoom_window_text": pygame.font.SysFont(None, 20), + } + self._category_button_surfaces_active: dict[str, pygame.Surface] = {} + self._category_button_surfaces_inactive: dict[str, pygame.Surface] = {} + self._particle_label_surfaces: dict[str, pygame.Surface] = {} + self._header_surface: pygame.Surface | None = None + self._clear_label_surface: pygame.Surface | None = None + self._settings_label_surface: pygame.Surface | None = None + self._sidebar_surface: pygame.Surface | None = None + self._use_surfarray = True + try: + test_view = pygame.surfarray.pixels3d(self.particle_surface) + del test_view + except Exception: + self._use_surfarray = False + # Pre-render static UI elements + self.button_surfaces = {} + + # Initialize categories + for name, properties in particle_properties.items(): + if "color" in properties: + self.particle_colors[name.lower()] = properties["color"] + self.categories: dict[str, list[str]] = { + "Solids": [], + "Liquids": [], + "Gases": [], + "Special": [], + } + for particle_name, properties in self.particle_properties.items(): + if properties.get("is_gas"): + self.categories["Gases"].append(particle_name) + elif properties.get("liquid"): + self.categories["Liquids"].append(particle_name) + elif properties.get("solid"): + self.categories["Solids"].append(particle_name) + else: + self.categories["Special"].append(particle_name) + + self.current_category = "Solids" + self.category_buttons: dict[str, pygame.Rect] = {} + self.setup_category_menu() + self.setup_static_ui() + + def setup_gpu_rendering(self): + """Initialize OpenGL context""" + pygame.display.gl_set_attribute(pygame.GL_ACCELERATED_VISUAL, 1) + self.screen = pygame.display.set_mode( + (self.width, self.height), pygame.OPENGL | pygame.DOUBLEBUF + ) + + def setup_static_ui(self) -> None: + """Initialize UI elements and styles""" + # UI Colors + self.ui_bg = (30, 30, 35, 230) + self.ui_accent = (70, 130, 180) # Steel Blue + self.ui_text = (240, 240, 250) + self.ui_hover = (50, 50, 60) + + self.sidebar_width = 160 + self.bottom_bar_height = 50 + + self._sidebar_surface = pygame.Surface((self.sidebar_width, self.height), pygame.SRCALPHA) + self._sidebar_surface.fill(self.ui_bg) + + self.category_buttons = {} + self.particle_buttons = {} + self.button_surfaces = {} + + # Setup categories and particles + y_offset = 60 + category_height = 35 + + for i, category in enumerate(self.categories.keys()): + rect = pygame.Rect(10, y_offset, self.sidebar_width - 20, category_height) + self.category_buttons[category] = rect + + # Pre-render category label + surf = pygame.Surface((rect.width, rect.height), pygame.SRCALPHA) + pygame.draw.rect(surf, self.ui_accent, (0, 0, rect.width, rect.height), border_radius=8) + + font = self.cached_fonts.get("button") + if font: + label = font.render(category.title(), True, self.ui_text) + surf.blit(label, (10, 8)) + self._category_button_surfaces_active[category] = surf + inactive = surf.copy() + inactive.set_alpha(140) + self._category_button_surfaces_inactive[category] = inactive + + y_offset += category_height + 5 + + header_font = self.cached_fonts.get("settings") + if header_font: + self._header_surface = header_font.render("SandPyPi", True, self.ui_accent) + + button_font = self.cached_fonts.get("button") + if button_font: + self._clear_label_surface = button_font.render("Clear Grid", True, (255, 255, 255)) + self._settings_label_surface = button_font.render("Settings", True, (255, 255, 255)) + + if button_font: + for particle_name in self.particle_properties.keys(): + self._particle_label_surfaces[particle_name] = button_font.render( + particle_name[:10], True, (0, 0, 0) + ) + + def setup_category_menu(self): + """Setup category menu""" + # This method is now largely handled by setup_static_ui, but keeping it for potential future use + # or if other parts of the code still call it. + pass + + def draw_buttons(self, mouse_pos: tuple[int, int], current_particle_type: str) -> None: + """Draw the particle and category selection UI.""" + # Clear previous frame's layout to prevent ghost clicks + self.buttons = {} + + # Draw Sidebar Background + if self._sidebar_surface: + self.screen.blit(self._sidebar_surface, (0, 0)) + + # Header + if self._header_surface: + self.screen.blit(self._header_surface, (15, 15)) + + # Draw Category Icons/Buttons (Tabs) + for category, rect in self.category_buttons.items(): + if category == self.current_category: + surf = self._category_button_surfaces_active.get(category) + else: + surf = self._category_button_surfaces_inactive.get(category) + if surf: + self.screen.blit(surf, rect) + + # Hover highlight + if rect.collidepoint(mouse_pos): + pygame.draw.rect(self.screen, (255, 255, 255, 40), rect, border_radius=8) + + # Draw Particle Buttons for current category + y_offset = 250 # Below categories + button_width = (self.sidebar_width - 30) // 2 + button_height = 24 + x_start = 10 + + row = 0 + col = 0 + + for particle_type in self.categories[self.current_category]: + if particle_type in self.particle_properties: + btn_x = x_start + col * (button_width + 5) + btn_y = y_offset + row * (button_height + 5) + + rect = pygame.Rect(btn_x, btn_y, button_width, button_height) + self.buttons[particle_type] = rect + + # Draw button + color = self.particle_properties[particle_type].get("color", (200, 200, 200)) + if len(color) > 3: color = color[:3] + + # Dynamic styling + is_selected = (particle_type.lower() == current_particle_type.lower()) + bg_color = (min(color[0] + 50, 255), min(color[1] + 50, 255), min(color[2] + 50, 255)) if is_selected else color + + # Hover effect + if rect.collidepoint(mouse_pos): + bg_color = (min(bg_color[0] + 30, 255), min(bg_color[1] + 30, 255), min(bg_color[2] + 30, 255)) + + pygame.draw.rect(self.screen, bg_color, rect, border_radius=4) + if is_selected: + pygame.draw.rect(self.screen, (255, 255, 255), rect, 2, border_radius=4) + + label = self._particle_label_surfaces.get(particle_type) + if label: + self.screen.blit(label, (btn_x + 4, btn_y + 4)) + + col += 1 + if col > 1: + col = 0 + row += 1 + + # Draw Control Buttons (Bottom) + self.clear_screen_button = pygame.Rect(10, self.height - 90, self.sidebar_width - 20, 30) + pygame.draw.rect(self.screen, (180, 50, 50), self.clear_screen_button, border_radius=8) + if self._clear_label_surface: + self.screen.blit( + self._clear_label_surface, + (self.clear_screen_button.x + 25, self.clear_screen_button.y + 6), + ) + + self.settings_button = pygame.Rect(10, self.height - 50, self.sidebar_width - 20, 30) + pygame.draw.rect(self.screen, (100, 100, 100), self.settings_button, border_radius=8) + if self._settings_label_surface: + self.screen.blit( + self._settings_label_surface, + (self.settings_button.x + 35, self.settings_button.y + 6), + ) + + + def load_buttons(self): + """Load buttons""" + # This method is now integrated into draw_buttons and setup_static_ui + pass + + def draw_particles( + self, + particles: list[list[Particle | None]], + active_particles: set[tuple[int, int]], + particle_size: int, + particle_colors: dict[str, tuple[int, int, int] | list[int]], + ) -> None: + """Draw particles""" + if self._use_surfarray: + self._draw_particles_surfarray( + particles, + active_particles, + particle_size, + particle_colors, + ) + return + + self.particle_surface.fill((0, 0, 0, 0)) + + particle_batches: dict[tuple[int, int, int, int], list[pygame.Rect]] = {} + gas_effect = engine_settings["enable_gas_effect"] + glow = engine_settings["enable_glow"] + + for x, y in active_particles: + particle = particles[x][y] + if not particle: + continue + p_type = particle.particle_type + if particle.particle_type in particle_colors: + color = particle_colors[particle.particle_type] + else: + color = (255, 255, 255) + + # Spark visualization + if hasattr(particle, 'spark_time') and particle.spark_time > 0: + color = (255, 255, 150) # Bright electrical yellow + + if glow: + glow_color = (255, 255, 255) + glow_radius = 0.5 * particle_size + glow_surface = pygame.Surface( + (glow_radius * 2, glow_radius * 2), pygame.SRCALPHA + ) + pygame.draw.circle( + glow_surface, + glow_color, + (glow_radius, glow_radius), + glow_radius, + ) + glow_surface.set_alpha(85) + self.particle_surface.blit( + glow_surface, + ( + x * particle_size - glow_radius, + y * particle_size - glow_radius, + ), + ) + + if gas_effect and particle.is_gas: + # Enhanced gas visibility + color = list(color) + if len(color) < 4: + color.append(255) + else: + color[3] = 255 + + # Add subtle movement effect + offset_x = random.randint(-1, 1) + offset_y = random.randint(-1, 1) + rect = ( + x * particle_size + offset_x, + y * particle_size + offset_y, + particle_size, + particle_size, + ) + pygame.draw.rect(self.particle_surface, tuple(color), rect) + continue + + rect = pygame.Rect( + x * particle_size, + y * particle_size, + particle_size, + particle_size, + ) + color_tuple = tuple(color) if isinstance(color, (list, tuple)) else (255, 255, 255) + if len(color_tuple) == 3: + color_tuple = (color_tuple[0], color_tuple[1], color_tuple[2], 255) + particle_batches.setdefault(color_tuple, []).append(rect) + + for color, rects in particle_batches.items(): + if not rects: + continue + if hasattr(pygame.draw, "rects"): + pygame.draw.rects(self.particle_surface, color, rects) + else: + for rect in rects: + pygame.draw.rect(self.particle_surface, color, rect) + + self.screen.blit(self.background, (0, 0)) + self.screen.blit(self.particle_surface, (0, 0)) + """#Potentially for future + for p_type, rects in particle_batches.items(): + color = particle_colors.get(p_type, (255, 255, 255)) + rect = (x * particle_size, y * particle_size, + particle_size, particle_size) + if len(rects) > 1: + pygame.draw.rect(self.particle_surface, color, rect) + else: + pygame.draw.rect(self.particle_surface, color, rect) + + self.screen.blit(self.background, (0, 0)) + self.screen.blit(self.particle_surface, (0, 0))""" + + def draw_particles_fast( + self, + type_id: np.ndarray, + particle_size: int, + color_lut: np.ndarray | None, + spark_time: np.ndarray | None = None, + ) -> None: + """Draw particles using fast grid data.""" + if not self._use_surfarray: + self.particle_surface.fill((0, 0, 0, 0)) + width = type_id.shape[0] + height = type_id.shape[1] + for x in range(width): + for y in range(height): + tid = int(type_id[x, y]) + if tid == 0: + continue + if spark_time is not None and spark_time[x, y] > 0: + color = (255, 255, 150) + else: + color = (255, 255, 255) + if color_lut is not None: + c = color_lut[tid] + color = (int(c[0]), int(c[1]), int(c[2])) + rect = pygame.Rect( + x * particle_size, + y * particle_size, + particle_size, + particle_size, + ) + pygame.draw.rect(self.particle_surface, color, rect) + + self.screen.blit(self.background, (0, 0)) + self.screen.blit(self.particle_surface, (0, 0)) + return + + pixels = pygame.surfarray.pixels3d(self.particle_surface) + alpha = pygame.surfarray.pixels_alpha(self.particle_surface) + pixels[:] = 0 + alpha[:] = 0 + + ps = particle_size + width = self.width + height = self.height + + positions = np.argwhere(type_id > 0) + for idx in range(positions.shape[0]): + x = int(positions[idx, 0]) + y = int(positions[idx, 1]) + px = x * ps + py = y * ps + if px >= width or py >= height: + continue + + x0 = px if px >= 0 else 0 + y0 = py if py >= 0 else 0 + x1 = px + ps + y1 = py + ps + + if x1 <= 0 or y1 <= 0: + continue + + if x1 > width: + x1 = width + if y1 > height: + y1 = height + + if spark_time is not None and spark_time[x, y] > 0: + pixels[x0:x1, y0:y1] = (255, 255, 150) + else: + if color_lut is not None: + c = color_lut[int(type_id[x, y])] + pixels[x0:x1, y0:y1] = (int(c[0]), int(c[1]), int(c[2])) + else: + pixels[x0:x1, y0:y1] = (255, 255, 255) + alpha[x0:x1, y0:y1] = 255 + + del pixels + del alpha + + self.screen.blit(self.background, (0, 0)) + self.screen.blit(self.particle_surface, (0, 0)) + + def _draw_particles_surfarray( + self, + particles: list[list[Particle | None]], + active_particles: set[tuple[int, int]], + particle_size: int, + particle_colors: dict[str, tuple[int, int, int] | list[int]], + ) -> None: + """Draw particles using surfarray for faster pixel writes.""" + gas_effect = engine_settings["enable_gas_effect"] + glow = engine_settings["enable_glow"] + ps = particle_size + width = self.width + height = self.height + randint = random.randint + + pixels = pygame.surfarray.pixels3d(self.particle_surface) + alpha = pygame.surfarray.pixels_alpha(self.particle_surface) + pixels[:] = 0 + alpha[:] = 0 + + glow_positions: list[tuple[int, int]] = [] + + for x, y in active_particles: + particle = particles[x][y] + if not particle: + continue + + if glow: + glow_positions.append((x, y)) + + color_val = particle_colors.get(particle.particle_type, (255, 255, 255)) + + # Spark visualization override + if hasattr(particle, "spark_time") and particle.spark_time > 0: + color_val = (255, 255, 150) + + if isinstance(color_val, (list, tuple)): + if len(color_val) >= 3: + r, g, b = int(color_val[0]), int(color_val[1]), int(color_val[2]) + a = int(color_val[3]) if len(color_val) > 3 else 255 + else: + r, g, b, a = 255, 255, 255, 255 + else: + r, g, b, a = 255, 255, 255, 255 + + if gas_effect and particle.is_gas: + a = 255 + px = x * ps + randint(-1, 1) + py = y * ps + randint(-1, 1) + else: + px = x * ps + py = y * ps + + if px >= width or py >= height: + continue + + x0 = px if px >= 0 else 0 + y0 = py if py >= 0 else 0 + x1 = px + ps + y1 = py + ps + + if x1 <= 0 or y1 <= 0: + continue + + if x1 > width: + x1 = width + if y1 > height: + y1 = height + + pixels[x0:x1, y0:y1] = (r, g, b) + alpha[x0:x1, y0:y1] = a + + del pixels + del alpha + + if glow: + glow_color = (255, 255, 255) + glow_radius = max(1, int(0.5 * ps)) + for x, y in glow_positions: + pygame.draw.circle( + self.particle_surface, + glow_color, + (x * ps + glow_radius, y * ps + glow_radius), + glow_radius, + ) + + self.screen.blit(self.background, (0, 0)) + self.screen.blit(self.particle_surface, (0, 0)) + + def draw_zoom_window( + self, + particles: list[list[Particle | None]], + particle_size: int, + particle_colors: dict[str, tuple[int, int, int] | list[int]], + mouse_pos: tuple[int, int], + zoom_factor: int = 4, + ) -> "pg_typing.Surface": + """Draw zoom window""" + print(f"Drawing zoom window.{mouse_pos}") + zoom_size = 100 # Size of zoom window + zoom_surface = pygame.Surface((zoom_size, zoom_size)) + zoom_surface.fill((0, 0, 0)) + + # Get area around mouse to zoom + mouse_x, mouse_y = mouse_pos + view_x = mouse_x - zoom_size / (2 * zoom_factor) + view_y = mouse_y - zoom_size / (2 * zoom_factor) + print(f"Viewing area: {view_x}, {view_y}") + + # Draw zoomed particles + for x in range(int(view_x), int(view_x + zoom_size / zoom_factor)): + for y in range(int(view_y), int(view_y + zoom_size / zoom_factor)): + if 0 <= x < len(particles) and 0 <= y < len(particles[0]): + particle = particles[x][y] + if particle: + color = particle_colors.get( + particle.particle_type, (255, 255, 255) + ) + rect = ( + (x - view_x) * zoom_factor, + (y - view_y) * zoom_factor, + particle_size * zoom_factor, + particle_size * zoom_factor, + ) + pygame.draw.rect(zoom_surface, color, rect) + print(f"Drawing zoom window at {mouse_pos}{zoom_surface}") + return zoom_surface + + def _get_particle_info(self, particles, x, y): + if 0 <= x < len(particles) and 0 <= y < len(particles[0]): + particle = particles[x][y] + if particle: + attrs = [ + "temperature", + "liquid", + "is_gas", + "solid", + "mass", + "velocity", + "friction", + ] + info = [f"Type: {particle.particle_type}"] + info.extend( + f"{attr}: {getattr(particle, attr)}" + for attr in attrs + if hasattr(particle, attr) + ) + return " | ".join(info) + return "None" + + """ + def draw_wind_overlay(self, wind_zones): + wind_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + + for zone in wind_zones: + # Draw wind direction arrows + x, y = zone["x"], zone["y"] + # radius = zone["radius"] + strength = zone["strength"] + direction = zone["direction"] + + # Draw wind field visualization + arrow_color = (0, 150, 255, 100) # Light blue, semi-transparent + arrow_length = strength * 20 + + # Draw main direction arrow + end_x = x + direction[0] * arrow_length + end_y = y + direction[1] * arrow_length + pygame.draw.line( + wind_surface, arrow_color, (x, y), (end_x, end_y), 2 + ) + + # Draw arrow head + pygame.draw.circle(wind_surface, arrow_color, (int(x), int(y)), 5) + + self.screen.blit(wind_surface, (0, 0))""" + + """ + def draw_pressure_overlay(self, particles, active_particles): + pressure_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + + # Create pressure map + for x, y in active_particles: + particle = particles[x][y] + if particle and hasattr(particle, "pressure"): + # Color gradient based on pressure + pressure = particle.pressure + if pressure > 0: + color = ( + 255, + 0, + 0, + int(min(pressure * 50, 255)), + ) # Red for high pressure + else: + color = ( + 0, + 0, + 255, + int(min(-pressure * 50, 255)), + ) # Blue for low pressure + + pygame.draw.rect( + pressure_surface, + color, + ( + x * self.particle_size, + y * self.particle_size, + self.particle_size, + self.particle_size, + ), + ) + + self.screen.blit(pressure_surface, (0, 0))""" + + """ + def draw_temperature_overlay(self, particles, active_particles): + temperature_surface = pygame.Surface( + (self.width, self.height), pygame.SRCALPHA + ) + + # Create temperature map + for x, y in active_particles: + particle = particles[x][y] + if particle and hasattr(particle, "temperature"): + # Color gradient based on temperature + temperature = particle.temperature + if temperature > 0: + color = ( + 255, + 0, + 0, + int(min(temperature * 50, 255)), + ) # Red for high temperature + else: + color = ( + 0, + 0, + 255, + int(min(-temperature * 50, 255)), + ) # Blue for low temperature + + pygame.draw.rect( + temperature_surface, + color, + ( + x * self.particle_size, + y * self.particle_size, + self.particle_size, + self.particle_size, + ), + ) + + self.screen.blit(temperature_surface, (0, 0))""" + + def draw_debug_overlay(self, fps: float, sim: "Simulation") -> None: + """Debugger moving to debugger_system.py""" + + if ( + not engine_settings["enable_fps"] + and not engine_settings["enable_debug"] + ): + return + + # Create static debug surface if not exists + if not hasattr(self, "debug_surface"): + self.debug_surface = pygame.Surface((300, 150), pygame.SRCALPHA) + + # Only update when values change significantly + mouse_x, mouse_y = pygame.mouse.get_pos() + cell_size = getattr(sim, "particle_size", 3) + grid_x, grid_y = mouse_x // cell_size, mouse_y // cell_size + tps = sim.track_tps() + + current_info = ( + int(fps), + int(tps), + mouse_x // 10, # Reduced update frequency + mouse_y // 10, + sim.particle_count, + ) + + if ( + not hasattr(self, "_last_debug_info") + or current_info != self._last_debug_info + ): + self._last_debug_info = current_info + self.debug_surface.fill((0, 0, 0, 0)) + font = self.cached_fonts["debug"] + y_offset = 10 + + if engine_settings["enable_fps"]: + fps_surf = font.render( + f"FPS: {fps:.1f} | TPS: {tps:.1f}", + True, + (255, 255, 255), + ) + self.debug_surface.blit(fps_surf, (10, y_offset)) + y_offset += 25 + + if engine_settings["enable_debug"]: + p_info = "None" + if 0 <= grid_x < sim.width and 0 <= grid_y < sim.height: + if getattr(sim, "fast_mode", False) and getattr(sim, "fast_type_id", None) is not None: + tid = int(sim.fast_type_id[grid_x, grid_y]) + if tid != 0 and getattr(sim, "fast_id_to_type", None): + ptype = sim.fast_id_to_type[tid] + if getattr(sim, "fast_temp", None) is not None: + ptemp = float(sim.fast_temp[grid_x, grid_y]) + p_info = f"{ptype.title()} (T: {ptemp:.1f}C)" + else: + p_info = f"{ptype.title()}" + else: + hovered_p = sim.particles[grid_x][grid_y] + if hovered_p: + p_info = f"{hovered_p.particle_type.title()} (T: {hovered_p.temperature:.1f}C)" + + debug_lines = [ + f"Mouse: ({mouse_x}, {mouse_y})", + f"Grid: ({grid_x}, {grid_y})", + f"Particles: {sim.particle_count}", + f"Hover: {p_info}" + ] + + for line in debug_lines: + text_surf = font.render(line, True, (255, 255, 255)) + self.debug_surface.blit(text_surf, (10, y_offset)) + y_offset += 25 + + # Single blit of cached surface, offset from sidebar + self.screen.blit(self.debug_surface, (self.sidebar_width + 10, 10)) + + def render_brush_cursor(self, x: int, y: int, radius: int) -> None: + """Draw outline circle""" + if engine_settings["enable_cursor"]: + pygame.draw.circle(self.screen, (255, 255, 255), (x, y), radius, 1) + # Draw slightly transparent fill + cursor_surface = pygame.Surface( + (radius * 2, radius * 2), pygame.SRCALPHA + ) + pygame.draw.circle( + cursor_surface, (255, 255, 255, 55), (radius, radius), radius + ) + self.screen.blit(cursor_surface, (x - radius, y - radius)) + + def draw_brush_size_slider(self, brush_size: int) -> None: + """Draw the slider for brush size""" + pygame.draw.rect(self.screen, (255, 255, 255), (500, 10, 100, 20)) + pygame.draw.rect(self.screen, (0, 0, 0), (500, 10, 100, 20), 2) + pygame.draw.rect( + self.screen, + (255, 0, 0), + (500 + brush_size, 10, 100 - brush_size * 2, 20), + ) + font = self.cached_fonts.get("brush_size") + if font is not None: + label = font.render(f"Brush Size: {brush_size}", True, (255, 255, 255)) + self.screen.blit(label, (500, 10)) + + def draw_settings_menu(self): + """Draw the settings menu""" + settings_surface = pygame.Surface((300, 400)) + settings_surface.fill((50, 50, 50)) + + y_offset = 10 + font = self.cached_fonts.get("settings") + if font is None: + return settings_surface + + for setting, value in engine_settings.items(): + # Create toggle button + button_rect = pygame.Rect(10, y_offset, 20, 20) + pygame.draw.rect( + settings_surface, + (0, 255, 0) if value else (255, 0, 0), + button_rect, + ) + + # Draw setting name + label = font.render( + setting.replace("_", " ").title(), True, (255, 255, 255) + ) + settings_surface.blit(label, (40, y_offset)) + + y_offset += 30 + + return settings_surface + + def clear_screen(self, sim: "Simulation") -> None: + """Store current particle type""" + current_type = sim.current_particle_type + self.particle_count = 0 + # Reset simulation grid while preserving particle type + if getattr(sim, "fast_mode", False) and getattr(sim, "fast_type_id", None) is not None: + sim.fast_type_id.fill(0) + if getattr(sim, "fast_temp", None) is not None: + sim.fast_temp.fill(sim.ambient_temperature) + if getattr(sim, "fast_burn_time", None) is not None: + sim.fast_burn_time.fill(0) + if getattr(sim, "fast_burning", None) is not None: + sim.fast_burning.fill(0) + if getattr(sim, "fast_spark_time", None) is not None: + sim.fast_spark_time.fill(0) + if getattr(sim, "fast_lifetime", None) is not None: + sim.fast_lifetime.fill(0) + sim.active_particles.clear() + sim.occupied_cells.clear() + sim.current_particle_type = current_type + sim.particle_count = 0 + else: + sim.particles = [ + [None for _ in range(sim.height)] for _ in range(sim.width) + ] + sim.active_particles.clear() + sim.current_particle_type = current_type + sim.reset_particle_count() + # Clear display surfaces + self.background.fill((0, 0, 0)) + self.particle_surface.fill((0, 0, 0, 0)) diff --git a/src/template_particles.json b/python/src/template_particles.json similarity index 100% rename from src/template_particles.json rename to python/src/template_particles.json diff --git a/src/rendering/py.typed b/python/src/ui/__init__.py similarity index 100% rename from src/rendering/py.typed rename to python/src/ui/__init__.py diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e5b394d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -numpy -pygame \ No newline at end of file diff --git a/run-sand.bat b/run-sand.bat new file mode 100644 index 0000000..d469612 --- /dev/null +++ b/run-sand.bat @@ -0,0 +1,4 @@ +@ECHO OFF +pushd %~dp0 +dotnet run --project Sand.App\Sand.App.csproj +popd diff --git a/sdtconfig-sandpypi.json b/sdtconfig-sandpypi.json new file mode 100644 index 0000000..05187e4 --- /dev/null +++ b/sdtconfig-sandpypi.json @@ -0,0 +1,270 @@ +{ + "name": "sandpypi", + "version": "0.1.0", + "targets": [], + "workflows": [ + { + "id": "build", + "label": "Build", + "description": "Build detected project stacks", + "group": "Build", + "dependsOn": [], + "requireFiles": [], + "steps": [ + { + "id": "dotnet-build", + "label": "dotnet build", + "command": null, + "args": [], + "workingDir": ".", + "action": "dotnet-build", + "actionArgs": [], + "requires": [] + } + ] + }, + { + "id": "deps-refresh", + "label": "Refresh Dependencies", + "description": "Restore/install dependency stacks", + "group": "Deps", + "dependsOn": [], + "requireFiles": [], + "steps": [ + { + "id": "dotnet-restore", + "label": "dotnet restore", + "command": null, + "args": [], + "workingDir": ".", + "action": "dotnet-restore", + "actionArgs": [], + "requires": [] + }, + { + "id": "python-pip-sync", + "label": "python pip sync", + "command": null, + "args": [], + "workingDir": ".", + "action": "python-pip-sync", + "actionArgs": [ + "--requirements", + "requirements.txt" + ], + "requires": [] + } + ] + }, + { + "id": "test", + "label": "Run Tests", + "description": "Run detected test stacks", + "group": "Test", + "dependsOn": [], + "requireFiles": [], + "steps": [ + { + "id": "dotnet-test", + "label": "dotnet test", + "command": null, + "args": [], + "workingDir": ".", + "action": "dotnet-test", + "actionArgs": [], + "requires": [] + }, + { + "id": "python-pytest", + "label": "python -m pytest", + "command": null, + "args": [], + "workingDir": ".", + "action": "python-pytest", + "actionArgs": [], + "requires": [] + } + ] + }, + { + "id": "repo-health", + "label": "Repo Health", + "description": "Check repo status and fetch remotes", + "group": "Repo", + "dependsOn": [], + "requireFiles": [], + "steps": [ + { + "id": "git-status", + "label": "git status", + "command": null, + "args": [], + "workingDir": ".", + "action": "git-status", + "actionArgs": [], + "requires": [] + }, + { + "id": "git-fetch", + "label": "git fetch", + "command": null, + "args": [], + "workingDir": ".", + "action": "git-fetch", + "actionArgs": [], + "requires": [] + } + ] + } + ], + "env": [ + { + "key": "SDT_LOG_LEVEL", + "description": "CLI log verbosity", + "default": "information", + "options": [ + "trace", + "debug", + "information", + "warning", + "error", + "critical" + ] + } + ], + "envProfiles": { + "active": "dev", + "profiles": [ + { + "id": "dev", + "description": "Local development defaults", + "inherits": [], + "values": { + "SDT_ENV_PROFILE": "dev", + "SDT_LOG_LEVEL": "information" + } + }, + { + "id": "ci", + "description": "Continuous integration defaults", + "inherits": [ + "dev" + ], + "values": { + "SDT_ENV_PROFILE": "ci", + "CI": "true", + "SDT_LOG_LEVEL": "warning" + } + }, + { + "id": "release", + "description": "Release build defaults", + "inherits": [ + "dev" + ], + "values": { + "SDT_ENV_PROFILE": "release", + "SDT_LOG_LEVEL": "warning" + } + } + ] + }, + "toolchains": { + "python": { + "executable": "python", + "windowsExecutable": "py", + "launcherVersion": null, + "venvDir": ".venv", + "profiles": [], + "pipScript": null + }, + "node": null + }, + "tooling": { + "defaultInstallPolicy": "Prompt", + "tools": [ + { + "tool": "dotnet", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "git", + "preferredInstallCommands": [], + "executables": [] + }, + { + "tool": "python", + "preferredInstallCommands": [], + "executables": [] + } + ] + }, + "project": { + "type": "polyglot", + "rootHints": [ + "*.sln", + ".git", + "requirements.txt", + "scripts" + ], + "artifacts": [ + "bin", + "obj", + ".sdt/debug" + ] + }, + "debug": { + "profiles": [ + { + "id": "dotnet-run", + "label": "Run .NET app", + "type": "dotnet", + "command": "dotnet", + "args": [ + "run" + ], + "workingDir": ".", + "env": {}, + "requires": [ + { + "tool": "dotnet", + "installPolicy": "Prompt" + } + ], + "attach": { + "kind": "manual", + "port": null, + "processName": null, + "note": "Attach your IDE debugger to the running dotnet process." + } + } + ], + "diagnostics": { + "enabled": true, + "outputDir": ".sdt/debug", + "includeAllEnv": false, + "captureEnvKeys": [ + "SDT_LOG_LEVEL", + "DOTNET_CLI_HOME", + "NUGET_PACKAGES", + "PIP_CACHE_DIR", + "NVM_HOME", + "NVM_SYMLINK" + ], + "redactSensitive": true, + "sensitiveKeyPatterns": [ + "TOKEN", + "SECRET", + "PASSWORD", + "PWD", + "CREDENTIAL", + "API_KEY", + "ACCESS_KEY", + "PRIVATE_KEY" + ], + "redactionAllowKeys": [], + "bundleOnFailure": true + } + } +} diff --git a/src/part/coreparts/states/molten.json b/src/part/coreparts/states/molten.json deleted file mode 100644 index 3e6a42b..0000000 --- a/src/part/coreparts/states/molten.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "molten_stone": { - "name": "Molten Stone", - "size": 1, - "hardness": 0.2, - "velocity": 0.3, - "conductivity": 1, - "heat_capacity": 1, - "color": [255, 140, 0, 255], - "mass": 0.8, - "temperature": 1200, - "solidify": "stone", - "solidify_temperature": 800, - "friction": 0.8, - "viscosity": 0.8, - "liquid": true, - "solid": false, - "is_gas": false - }, - "molten_glass": { - "name": "Molten Glass", - "size": 1, - "hardness": 0.2, - "velocity": 0.4, - "conductivity": 0.8, - "heat_capacity": 1, - "color": [255, 200, 150, 200], - "mass": 0.6, - "temperature": 700, - "solidify": "glass", - "solidify_temperature": 599, - "friction": 0.7, - "viscosity": 0.9, - "liquid": true, - "solid": false, - "is_gas": false - }, - "molten_rock": { - "name": "Molten Rock", - "size": 1, - "hardness": 0.2, - "velocity": 0.3, - "conductivity": 1, - "heat_capacity": 1, - "color": [255, 140, 0, 255], - "mass": 0.8, - "temperature": 600, - "solidify": "rock", - "solidify_temperature": 300, - "friction": 0.8, - "viscosity": 0.8, - "liquid": true, - "solid": false, - "is_gas": false - } -} \ No newline at end of file diff --git a/src/physics/particle.py b/src/physics/particle.py deleted file mode 100644 index 2916208..0000000 --- a/src/physics/particle.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Load particle properties from json so we know what particles -we got and how they should be simulated. -""" - - -class Particle: - """Set up a particle with the given properties.""" - - def __init__( - self, - simulation, - position, - velocity, - mass, - particle_type, - properties, - temperature=20, - ): - self.position = position # (x, y) - self.velocity = velocity # (vx, vy) - self.mass = mass - self.particle_type = particle_type - self.sim = simulation - - # Core properties - self.size = properties.get("size", 1) - self.hardness = properties.get("hardness", 0.5) - self.color = properties.get("color", [255, 255, 255, 255]) - self.temperature = properties.get("temperature", temperature) - self.durability = properties.get("durability", 100.0) - - # Physics properties - self.conductivity = properties.get("conductivity", 0) - self.heat_capacity = properties.get("heat_capacity", 1) - self.flamability = properties.get("flamability", 0.0) - self.friction = properties.get("friction", 0.5) - self.viscosity = properties.get("viscosity", 1.0) - self.pressure = properties.get("pressure", 0) - - # State properties - self.liquid = properties.get("liquid", False) - self.solid = properties.get("solid", True) - self.is_gas = properties.get("is_gas", False) - - # Temperature transition properties - self.melt = properties.get("melt", None) - self.melt_temperature = properties.get("melt_temperature", None) - self.solidify = properties.get("solidify", None) - self.solidify_temperature = properties.get( - "solidify_temperature", None - ) - self.evaporate = properties.get("evaporate", None) - self.evaporate_temperature = properties.get( - "evaporate_temperature", None - ) - self.freeze = properties.get("freeze", None) - self.freeze_temperature = properties.get("freeze_temperature", None) - - # Special properties - self.explosive = properties.get("explosive", False) - self.explosion_radius = properties.get("explosion_radius", 0) - self.explosion_color = properties.get("explosion_color", [0, 0, 0]) - self.explosion_force = properties.get("explosion_force", 0) - self.explosion_duration = properties.get("explosion_duration", 0) - - # Pressure properties - self.pressure_resistance = properties.get("pressure_resistance", 0) - self.pressure_tolerance = properties.get("pressure_tolerance", 0) - self.pressure_threshold = properties.get("pressure_threshold", 0) - self.pressure_threshold_duration = properties.get( - "pressure_threshold_duration", 0 - ) - - # Burning properties - self.burning = properties.get("burning", False) - self.burn_temperature = properties.get("burn_temperature", 0) - self.burn_duration = properties.get("burn_duration", 0) - self.burn_color = properties.get("burn_color", [255, 0, 0]) - self.burn_rate = properties.get("burn_rate", 0) - self.burn_intensity = properties.get("burn_intensity", 0) - self.burn_rate_multiplier = properties.get("burn_rate_multiplier", 1.0) - - @classmethod - def from_type(cls, simulation, position, particle_type, properties): - """Pre-initialize a particle with default values based on its type.""" - default_velocity = [0, 0] - default_mass = properties.get("mass", 1.0) - return cls( - simulation, - position, - default_velocity, - default_mass, - particle_type, - properties, - ) diff --git a/src/physics/sim.py b/src/physics/sim.py deleted file mode 100644 index 6a3085c..0000000 --- a/src/physics/sim.py +++ /dev/null @@ -1,803 +0,0 @@ -""" -#File Name: sim.py -Particle-based Physics Simulation System -====================================== - -This module implements a 2D particle simulation with physics, interactions, -and state changes. - -Key Components: --------------- -1. Particle Class - - Handles individual particle properties and behaviors - - Supports multiple particle types (solid, liquid, gas) - -2. Simulation Class - - Core simulation engine - - Manages particle creation, movement and interactions - - Handles physics calculations and spatial partitioning - - Manages temperature and state transitions -""" - -# Load the imports. -from ..config.settings import np, particle_properties, time -from ..physics.particle import Particle - - -class Simulation: - """the main class of the simulation.""" - - def __init__(self, width, height, x=0, y=0): - self.dormant_particles = set() - self.particle_movement_counter = {} - # self.DORMANT_THRESHOLD = 10 - self._tps_counter = 0 - self._tps_timer = time.time() - self._current_tps = 0 - self.x = x - self.y = y - self.new_x = 0 - self.new_y = 0 - self.width = width - self.height = height - self.particle_size = 3 - self.particles = [[None for _ in range(height)] for _ in range(width)] - self.particle_count = 0 - self.active_particles = set() - self.cell_size = 32 - self.spatial_grid = {} - self.brush_size = 1 - self.max_brush_size = 20 - self.particle_properties = particle_properties - self.particle_types = list(self.particle_properties.keys()) - self.current_particle_type = ( - self.particle_types[0] if self.particle_types else "sand" - ) - self.gravity = 9.8 # m/s^2 - self.wind_zones = [] - self.wind = [0.0, 0.0] # Global wind vector (x, y) - - def reset_particle_count(self): - """Reset and recalculate accurate particle count""" - active_count = np.sum( - [ - 1 - for x, y in self.active_particles - if self.particles[x][y] is not None - ] - ) - self.particle_count = int(active_count) - - def get_accurate_particle_count(self): - """Get current accurate particle count using numpy""" - particle_mask = np.array( - [ - [self.particles[x][y] is not None for y in range(self.height)] - for x in range(self.width) - ] - ) - return np.sum(particle_mask) - - def get_cell_key(self, x, y): - """Convert coordinates to grid cell""" - cell_x = x // self.cell_size - cell_y = y // self.cell_size - return (cell_x, cell_y) - - def add_to_spatial_grid(self, x, y): - """this is where we add to the spatial grid.""" - cell_key = self.get_cell_key(x, y) - if cell_key not in self.spatial_grid: - self.spatial_grid[cell_key] = set() - self.spatial_grid[cell_key].add((x, y)) - return cell_key - - def remove_from_spatial_grid(self, x, y): - """this is where we remove from the spatial grid.""" - cell_key = self.get_cell_key(x, y) - if cell_key in self.spatial_grid: - self.spatial_grid[cell_key].discard((x, y)) - return cell_key - - def _get_neighbors_from_grid(self, x, y): - """Get neighbors using spatial grid""" - cell_key = self.get_cell_key(x, y) - neighbors = [] - # Check current and adjacent cells - for dx in [-1, 0, 1]: - for dy in [-1, 0, 1]: - check_key = (cell_key[0] + dx, cell_key[1] + dy) - if check_key in self.spatial_grid: - neighbors.extend(self.spatial_grid[check_key]) - - return neighbors - - def update_spatial_grid(self): - """Enhanced spatial grid update""" - if len(self.active_particles) > 100: - self.spatial_grid = {} - cell_lists = {} - - # Track temperature-sensitive particles - temp_sensitive_cells = set() - - for x, y in self.active_particles: - cell_key = (x // self.cell_size, y // self.cell_size) - if cell_key not in cell_lists: - cell_lists[cell_key] = [] - cell_lists[cell_key].append((x, y)) - - # Mark cells with temperature-sensitive particles - particle = self.particles[x][y] - if hasattr(particle, "solidify_temperature") or hasattr( - particle, "melt_temperature" - ): - temp_sensitive_cells.add(cell_key) - - self.spatial_grid = {k: set(v) for k, v in cell_lists.items()} - return temp_sensitive_cells - - def handle_phase_transitions(self, particle, x, y): - """Handle all phase transitions for a particle""" - # state_changed = False - - # Check evaporation - if ( - hasattr(particle, "evaporate_temperature") - and particle.evaporate_temperature is not None - ): - if ( - particle.temperature >= particle.evaporate_temperature - and particle.evaporate - ): - self.transform_particle(x, y, particle.evaporate) - - # Check freezing - if ( - hasattr(particle, "freeze_temperature") - and particle.freeze_temperature is not None - ): - if ( - particle.temperature <= particle.freeze_temperature - and particle.freeze - ): - self.transform_particle(x, y, particle.freeze) - - # Check for melting with proper attribute validation - if ( - hasattr(particle, "melt") - and hasattr(particle, "melt_temperature") - and particle.melt_temperature is not None - ): - if particle.temperature >= particle.melt_temperature: - new_type = particle.melt - if new_type in self.particle_properties: - self.transform_particle(x, y, new_type) - - # Check for solidification with proper attribute validation - if ( - hasattr(particle, "solidify") - and hasattr(particle, "solidify_temperature") - and particle.solidify_temperature is not None - ): - if particle.temperature < particle.solidify_temperature: - new_type = particle.solidify - if new_type in self.particle_properties: - self.transform_particle(x, y, new_type) - return new_type - - if particle.particle_type == "steam": - # Steam should condense when it cools - if particle.temperature <= particle.solidify_temperature: - self.transform_particle(x, y, new_type) - return new_type - - # Check durability property from JSON - if ( - hasattr(particle, "durability") - and hasattr(particle, "brk") - and particle.brk is not None - ): - if particle.durability <= 0: - self.transform_particle(x, y, particle.broken) - return new_type - - def handle_particle_damage(self, particle, x, y): - """Handle damage calculations for particles with durability""" - if not hasattr(particle, "durability"): - return - - # Pressure damage - fx, fy = self.calculate_forces(particle, x, y) - pressure = ( - fx * fx + fy * fy - ) ** 0.5 # Calculate magnitude of force vector - - if ( - hasattr(particle, "pressure_threshold") - and pressure > particle.pressure_threshold - ): - particle.durability -= 0.1 - - # Impact damage - neighbors = self._get_neighbors_from_grid(x, y) - for nx, ny in neighbors: - neighbor = self.particles[nx][ny] - if neighbor and neighbor.velocity[1] > 5.0: - particle.durability -= 0.2 - - # Heat damage - if particle.temperature > 900: - particle.durability -= 1 - - # Check if particle should break - if particle.durability <= 0 and hasattr(particle, "broken"): - self.transform_particle(x, y, particle.broken) - return - - def handle_particle_interactions(self): - """Handle interactions between different particle types""" - for x, y in list(self.active_particles): - particle = self.particles[x][y] - - if not particle: - continue - - # Handle damage for any particle with durability - self.handle_particle_damage(particle, x, y) - - # Check neighboring particles - for dx, dy in [ - (-1, 0), - (1, 0), - (0, -1), - (0, 1), - (-1, -1), - (1, -1), - (-1, 1), - (1, 1), - ]: - nx, ny = x + dx, y + dy - if 0 <= nx < self.width and 0 <= ny < self.height: - neighbor = self.particles[nx][ny] - if neighbor: - self.process_interaction( - particle, neighbor, x, y, nx, ny - ) - - def process_interaction(self, particle1, particle2, x1, y1, x2, y2): - """Process specific interactions between two particles""" - # Water + Sand = Wet Sand - if ( - particle1.particle_type == "water" - and particle2.particle_type == "sand" - or particle2.particle_type == "water" - and particle1.particle_type == "sand" - ): - self.create_mud(x1, y1, "wsand") # Pass wsand type - self.particles[x2][y2] = None - self.active_particles.discard((x2, y2)) - - # Water + Dirt = Mud - if ( - particle1.particle_type == "water" - and particle2.particle_type == "dirt" - or particle2.particle_type == "water" - and particle1.particle_type == "dirt" - ): - self.create_mud(x1, y1, "mud") # Pass mud type - self.particles[x2][y2] = None - self.active_particles.discard((x2, y2)) - - # Lava/Fire effects - if particle1.particle_type in [ - "lava", - "fire", - "flame", - ] or particle2.particle_type in ["lava", "fire", "flame"]: - target = ( - particle2 - if particle1.particle_type in ["lava", "fire", "flame"] - else particle1 - ) - target_x, target_y = ( - (x2, y2) - if particle1.particle_type in ["lava", "fire", "flame"] - else (x1, y1) - ) - - # Water to Steam - if target.particle_type == "water": - self.transform_particle(target_x, target_y, "steam") - - # Wood to Fire - elif target.particle_type == "wood": - if np.random.random() < 0.3: # 30% chance to ignite - self.transform_particle(target_x, target_y, "fire") - - # Add plasma effects - if ( - particle1.particle_type == "plasma" - or particle2.particle_type == "plasma" - ): - target = ( - particle2 if particle1.particle_type == "plasma" else particle1 - ) - target_x, target_y = ( - (x2, y2) if particle1.particle_type == "plasma" else (x1, y1) - ) - - # Transfer high temperature to target - if hasattr(target, "temperature"): - target.temperature += 100 # Rapid temperature increase - - def create_mud(self, x, y, mud_type): - """Create either wet sand or mud based on the specified type""" - if mud_type in self.particle_properties: - properties = self.particle_properties[mud_type] - new_particle = Particle.from_type( - self, (x, y), mud_type, properties - ) - self.particles[x][y] = new_particle - self.active_particles.add((x, y)) - - def transform_particle(self, x, y, new_type): - """Transform a particle into a different type""" - if new_type in self.particle_properties: - properties = self.particle_properties[new_type] - new_particle = Particle.from_type( - self, (x, y), new_type, properties - ) - self.particles[x][y] = new_particle - self.active_particles.add((x, y)) - - def handle_gas_movement(self, particle, x, y): - """Handle gas particle movement""" - if particle.is_gas: - dx = np.random.uniform(-1, 1) - dy = np.random.uniform(-2, 0) # Bias upward movement - new_x = int(x + dx) - new_y = int(y + dy) - - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.particles[new_x][new_y] is None: - self.particles[x][y] = None - self.particles[new_x][new_y] = particle - self.active_particles.add((new_x, new_y)) - self.active_particles.discard((x, y)) - - def add_wind_zone(self, x, y): - """Instead of creating particles, store wind zone data""" - wind_zone = { - "x": x, - "y": y, - "radius": 50, - "strength": 2.0, - "direction": [1, 0], - } - self.wind_zones.append(wind_zone) - - def calculate_forces(self, particle, x, y): - """Calculate net forces acting on a particle.""" - # Initialize forces as numpy array for vectorized operations - forces = np.zeros(2, dtype=np.float32) # [fx, fy] - - # Vectorized wind zone calculations - # if self.wind_zones: - # positions = np.array([[zone['x'], zone['y']] for zone - # in self.wind_zones]) - # directions = np.array([zone['direction'] for zone - # in self.wind_zones]) - # strengths = np.array([zone['strength'] for zone - # in self.wind_zones]) - # radii = np.array([zone['radius'] for zone in self.wind_zones]) - - # Calculate distances vectorized - # dx = x - positions[:, 0] - # dy = y - positions[:, 1] - # distances = np.sqrt(dx**2 + dy**2) - - # Apply wind zone forces where distance <= radius - # mask = distances <= radii - # scale_factors = np.where(mask, (1 - distances/radii) * - # strengths[:, np.newaxis], 0) - # forces += np.sum(directions * scale_factors[:, np.newaxis], axis=0) - - # Apply global wind with vectorized operation - # wind_factor = 0.5 if particle.is_gas else 1.0 - # forces += np.array(self.wind) * wind_factor - - # Apply drag force vectorized - drag = particle.viscosity * -1 - forces += drag * np.array(particle.velocity) - - # Neighbor forces using numpy arrays - neighbors = np.array(self._get_neighbors_from_grid(x, y)) - if len(neighbors): - valid_mask = ( - (neighbors[:, 0] >= 0) - & (neighbors[:, 0] < self.width) - & (neighbors[:, 1] >= 0) - & (neighbors[:, 1] < self.height) - ) - neighbors = neighbors[valid_mask] - - for nx, ny in neighbors: - if (nx, ny) != (x, y): - neighbor = self.particles[nx][ny] - if neighbor: - self._apply_neighbor_forces( - neighbor, forces[0], forces[1] - ) - - return forces[0], forces[1] - - def _get_quick_neighbors(self, x, y): - """Quick neighbor lookup without full spatial grid""" - return [ - (x + dx, y + dy) for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)] - ] - - def _apply_neighbor_forces(self, particle, neighbor, fy): - """Optimized neighbor force calculation""" - if hasattr(neighbor, "temperature") and hasattr( - particle, "temperature" - ): - temp_diff = neighbor.temperature - particle.temperature - fy += temp_diff * 0.05 - - def ignite_particle(self, particle): - """Handle ignition and burning of flammable particles.""" - if hasattr(particle, "flamability") and particle.flamability > 0.5: - if hasattr(particle, "temperature") and particle.temperature > 150: - particle.type = "fire" - particle.temperature += 200 - # Add burning effect for wood - if particle.type == "wood": - particle.burning = True - particle.burn_time = 100 # burn time - - def spread_fire(self): # this is where we spread the fire. - """Spread fire to neighboring particles.""" - for x, y in list(self.active_particles): - particle = self.particles[x][y] - if particle and ( - particle.particle_type == "fire" - or getattr(particle, "burning", False) - ): - # Check all neighboring cells including diagonals - for dx in [-1, 0, 1]: - for dy in [-1, 0, 1]: - nx, ny = x + dx, y + dy - if 0 <= nx < self.width and 0 <= ny < self.height: - neighbor = self.particles[nx][ny] - if neighbor and hasattr(neighbor, "flamability"): - if neighbor.particle_type == "wood": - # Higher chance to ignite wood - if np.random.random() < 0.3: # 30% chance - self.ignite_particle(neighbor) - elif neighbor.flamability > 0: - if np.random.random() < 0.1: # 10% chance - self.ignite_particle(neighbor) - - def handle_temperature(self, dt): - """Handle temperature changes and state transitions""" - for x, y in list(self.active_particles): - particle = self.particles[x][y] - if particle and particle.is_gas: - particle.temperature -= 0.5 * dt - if not particle: - continue - - if particle.temperature > 30000: - # Transition to gas - particle.is_gas = True - particle.temperature = 30000 - particle.velocity = [ - np.random.uniform(-1, 1), - np.random.uniform(-1, 1), - ] - if particle.temperature <= 30000: - particle.is_gas = False - - # Temperature spread to neighbors - for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - nx, ny = x + dx, y + dy - if 0 <= nx < self.width and 0 <= ny < self.height: - neighbor = self.particles[nx][ny] - if ( - neighbor - and hasattr(neighbor, "temperature") - and neighbor.temperature is not None - ): - temp_diff = particle.temperature - neighbor.temperature - heat_transfer = temp_diff * 0.2 * dt - particle.temperature -= heat_transfer - neighbor.temperature += heat_transfer - - def burning(self): # this is where we handle the burning. - """Handle burning of particles.""" - for x, y in list(self.active_particles): - particle = self.particles[x][y] - if particle and hasattr(particle, "burning") and particle.burning: - particle.temperature += 10 - if particle.temperature > 1000: - self.particles[x][y] = None - self.active_particles.remove((x, y)) - self.spatial_grid.pop((x, y), None) - - def create_particle(self, x, y): # this is where we create the particle. - """Create a new particle with full property support""" - particle_type = self.current_particle_type.lower() - # Check if the particle is within the grid boundaries - if particle_type in self.particle_properties: - grid_x = x // self.particle_size - grid_y = y // self.particle_size - - if ( - 0 <= grid_x < self.width - and 0 <= grid_y < self.height - and self.particles[grid_x][grid_y] is None - ): - properties = self.particle_properties[particle_type] - position = (grid_x, grid_y) - new_particle = Particle( - simulation=self, - position=position, - velocity=[0, 0], - mass=properties.get("mass", 1.0), - particle_type=particle_type, - properties=properties, - ) - - self.particles[grid_x][grid_y] = new_particle - self.active_particles.add((grid_x, grid_y)) - self.particle_count += 1 - - def create_particle_circle(self, center_x, center_y): - """ "Create a circular pattern""" - brush_size = int(self.brush_size) - for dx in range(-brush_size, brush_size + 1): - for dy in range(-brush_size, brush_size + 1): - if ( - dx * dx + dy * dy <= brush_size * brush_size - ): # Circle check - self.create_particle( - center_x + dx * self.particle_size, - center_y + dy * self.particle_size, - ) - - def get_particle_state(self, x, y): - """Get the state of a particle at a given position""" - particle = self.particles[x][y] - if particle: - return particle.particle_type - return None - - def apply_physics(self, dt, engine_settings): - """Handle all physics and particle movement effects""" - new_active_particles = set() - # updates = [] - self.spatial_grid.clear() - - for x, y in list(self.active_particles): - particle = self.particles[x][y] - if not particle: - continue - - # Skip immutable particles - if particle.particle_type in ["wall", "stone", "wood"]: - new_active_particles.add((x, y)) - continue - - # Handle dissipating particles (fire/flame) - if particle.particle_type in ["fire", "flame"]: - self.particles[x][y] = None - self.active_particles.discard((x, y)) - - dx = np.random.uniform(-0.5, 0.5) - dy = np.random.uniform(-1.5, -0.5) - new_x, new_y = int(x + dx), int(y + dy) - - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.particles[new_x][new_y] is None: - self.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - - # Generate smoke - if np.random.random() < 0.25 and new_y > 0: - properties = self.particle_properties["smoke"] - new_smoke = Particle( - simulation=self, - position=(new_x, new_y - 1), - velocity=[np.random.uniform(-0.5, 0.5), -1], - mass=properties.get("mass", 0.1), - particle_type="smoke", - properties=properties, - ) - if self.particles[new_x][new_y - 1] is None: - self.particles[new_x][new_y - 1] = new_smoke - new_active_particles.add((new_x, new_y - 1)) - if np.random.random() < 0.02: - continue - - # Skip air particles - if particle.particle_type == "air": - continue - - # Handle phase transitions - self.handle_phase_transitions(particle, x, y) - - # Calculate forces and initial position - fx, fy = self.calculate_forces(particle, x, y) - new_x, new_y = x, y + 1 - - # Handle different particle types - if particle.particle_type in ["sand", "dirt", "snow", "ice"]: - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.particles[x][new_y] is None: - new_x, new_y = x, y + 1 - else: - diagonal_dirs = np.random.permutation( - [(-1, 1), (1, 1)] - ) - for dx, dy in diagonal_dirs: - test_x, test_y = x + dx, y + dy - if ( - 0 <= test_x < self.width - and 0 <= test_y < self.height - and self.particles[test_x][test_y] is None - ): - if np.random.random() < 0.8: - new_x, new_y = test_x, test_y - break - - elif particle.is_gas: - dx = np.random.uniform(2, -1) - dy = np.random.uniform(-2, 0) - new_x, new_y = int(x + dx), int(y + dy) - - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.particles[new_x][new_y] is None: - self.particles[x][y] = None - self.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - self.active_particles.discard((x, y)) - continue - - else: - # Regular particle physics - mass = max(particle.mass, 0.001) - particle.velocity[0] += (fx / mass) * dt - particle.velocity[1] += (fy / mass) * dt - - if particle.liquid: - new_y = min(y + 1, self.height - 1) - if 0 <= x < self.width and 0 <= y < self.height: - self.remove_from_spatial_grid(x, y) - if self.particles[x][new_y] is None: - new_x, new_y = x, y + 1 - else: - spread_directions = np.random.permutation( - [(-1, 0), (1, 0)] - ) - for dx, _ in spread_directions: - test_x = x + dx - if ( - 0 <= test_x < self.width - and self.particles[test_x][y] is None - ): - new_x, new_y = test_x, y - break - else: - new_x = int(x + particle.velocity[0] * dt) - new_y = int(y + particle.velocity[1] * dt) - - # Update particle position if valid - if 0 <= new_x < self.width and 0 <= new_y < self.height: - if self.particles[new_x][new_y] is None: - self.particles[x][y] = None - self.particles[new_x][new_y] = particle - new_active_particles.add((new_x, new_y)) - self.active_particles.discard((x, y)) - particle.position = (new_x, new_y) - self._wake_neighbors(new_x, new_y) - else: - new_active_particles.add((x, y)) - - # Handle boundaries - if x <= 0 or x >= self.width - 1 or y <= 0 or y >= self.height - 1: - if self.particles[x][y] is not None: - # Remove particle and update counts - self.particles[x][y] = None - self.active_particles.discard((x, y)) - self.remove_from_spatial_grid(x, y) - self.particle_count -= 1 - - self.active_particles = new_active_particles - - def clear_particles_circle(self, center_x, center_y): - """Clear particles in a circle around the given point based on brush""" - brush_size = int(self.brush_size) - particles_cleared = 0 # Track how many particles we clear - - for dx in range(-brush_size, brush_size + 1): - for dy in range(-brush_size, brush_size + 1): - if ( - dx * dx + dy * dy <= brush_size * brush_size - ): # Circle check - grid_x = ( - center_x + dx * self.particle_size - ) // self.particle_size - grid_y = ( - center_y + dy * self.particle_size - ) // self.particle_size - - if 0 <= grid_x < self.width and 0 <= grid_y < self.height: - if self.particles[grid_x][grid_y]: - self.particles[grid_x][grid_y] = None - self.active_particles.discard((grid_x, grid_y)) - self.remove_from_spatial_grid(grid_x, grid_y) - particles_cleared += 1 - - self.particle_count = max(0, self.particle_count - particles_cleared) - - """ - ###Future functionality### - def mix_liquids(self, liquid1, liquid2): # this is for the mix tool - # Handle liquid mixing interactions - if liquid1.temperature != liquid2.temperature: - avg_temp = (liquid1.temperature + liquid2.temperature) / 2 - liquid1.temperature = avg_temp - liquid2.temperature = avg_temp - liquid1.density = self.calculate_density(liquid1.temperature) - liquid2.density = self.calculate_density(liquid2.temperature) - liquid1.viscosity = self.calculate_viscosity(liquid1.temperature) - liquid2.viscosity = self.calculate_viscosity(liquid2.temperature) - liquid1.color = self.calculate_color(liquid1.temperature) - liquid2.color = self.calculate_color(liquid2.temperature) - """ - - def _wake_neighbors(self, x, y): - for dx in [-1, 0, 1]: - for dy in [-1, 0, 1]: - nx, ny = x + dx, y + dy - key = (nx, ny) - if key in self.dormant_particles: - self.dormant_particles.discard(key) - self.particle_movement_counter[key] = 0 - - def track_tps(self): - """Track Ticks Per Second for simulation performance monitoring""" - if not hasattr(self, "_tps_counter"): - self._tps_counter = 0 - self._tps_timer = time.time() - self._current_tps = 0 - - self._tps_counter += 1 - current_time = time.time() - elapsed = current_time - self._tps_timer - - # Update TPS count every second - if elapsed >= 1.0: - self._current_tps = self._tps_counter / elapsed - self._tps_counter = 0 - self._tps_timer = current_time - - return self._current_tps - - def simulate_step(self, dt, engine_settings): - """Run simulation step with spatial grid updates""" - - self.update_spatial_grid() - - # Update particle positions and physics - # self.apply_gravity() - self.apply_physics(dt, engine_settings) - - # Handle state changes and interactions - self.handle_temperature(dt) - - self.handle_particle_interactions() - self.burning() - self.spread_fire() diff --git a/template_particles.json b/template_particles.json new file mode 100644 index 0000000..67fe3bb --- /dev/null +++ b/template_particles.json @@ -0,0 +1,81 @@ +{ + "Template": { + "description": "Defines the properties and behavior of various particle types, including sand, water, and steam.", + "description2": "This template can be used as a starting point for creating custom particle mods.", + "description3": "Remove 'Template' for your own particle mods to work only particles like below will work." + }, + "sand": { + "name": "Sand", + "size": 1, + "hardness": 0.5, + "color": [255, 255, 0, 255], + "velocity": 0.5, + "mass": 0.5, + "conductivity": 0, + "heat_capacity": 1, + "flamability": 0.8, + "temperature": 0, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0, + "pressure": 0, + "melt": "molten-Glass", + "melt_temperature": 1000, + "conductive": false, + "liquid": false, + "solid": true, + "is_gas": false + }, + "water": { + "name": "Water", + "size": 1, + "hardness": 0.2, + "velocity": 0.3, + "conductivity": 1, + "heat_capacity": 1, + "color": [0, 0, 255, 255], + "mass": 1, + "flamability": 0, + "temperature": 22, + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 1, + "viscosity": 1, + "pressure": 0.5, + "evaporate": "steam", + "evaporate_temperature": 145, + "freeze": "ice", + "freeze_temperature": 0, + "melt": "water", + "melt_temperature": 20, + "liquid": true, + "solid": false, + "is_gas": false + }, + "steam": { + "name": "Steam", + "size": 1, + "hardness": 0.0, + "velocity": 0.2, + "conductivity": 1, + "heat_capacity": 1, + "color": [255, 255, 255, 255], + "mass": 0.01, + "flamability": 0, + "temperature": 100, + "solidify_temperature": 98, + "solidify": "water", + "explosive": false, + "explosion_radius": 0, + "explosion_color": [0, 0, 0], + "friction": 0.5, + "viscosity": 0.5, + "liquid": false, + "solid": false, + "is_gas": true, + "conductive": false + } +} \ No newline at end of file