Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d125e0d802 | |||
| fd737528ba | |||
| d05e64839f | |||
| c225632aac | |||
| c6c047fdb7 | |||
| dac5fa2213 | |||
| 1c8f6ab996 | |||
| 9c0812b3d9 | |||
| d2a50413db | |||
| 9c5a93ac3a | |||
| 64e45910a2 | |||
| b22f417c42 | |||
| 7a262c1cb9 | |||
|
|
7692acfdf7 |
191
.gitignore
vendored
191
.gitignore
vendored
@ -1,12 +1,10 @@
|
|||||||
# ---> Python
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
.mypy_cache/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
__pycache__/
|
|
||||||
# C extensions
|
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
@ -20,158 +18,49 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# Virtual Environment
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/#use-with-ide
|
|
||||||
.pdm.toml
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
venv/
|
||||||
|
env/
|
||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
.venv/
|
||||||
venv.bak/
|
.*_venv/
|
||||||
|
.env
|
||||||
|
|
||||||
# Spyder project settings
|
# IDE
|
||||||
.spyderproject
|
.idea/
|
||||||
.spyproject
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
# Rope project settings
|
# Testing
|
||||||
.ropeproject
|
.coverage
|
||||||
|
htmlcov/
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
# Custom Ignores
|
|
||||||
current_stations.html
|
|
||||||
forecast_data.json
|
|
||||||
openapi.json
|
|
||||||
__pycache__/sim.cpython-312.pyc
|
|
||||||
__pycache__/sim.cpython-312.pyc
|
|
||||||
__pycache__/rendering.cpython-312.pyc
|
|
||||||
sandpypi.dist/
|
|
||||||
sandpypi.build/
|
|
||||||
sandpypi.onefile-build/
|
|
||||||
sandpypi.exe
|
|
||||||
sandpypi.7z
|
|
||||||
unittest/
|
unittest/
|
||||||
.7z
|
.pytest_cache/
|
||||||
.zip
|
|
||||||
|
# Distribution
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
docs/
|
||||||
|
*.exe
|
||||||
|
output/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
livenotes.txt
|
livenotes.txt
|
||||||
|
AGENT.md
|
||||||
|
.pyrightignore
|
||||||
|
.gitignore
|
||||||
|
sandpypi-old.7z
|
||||||
|
.sdt
|
||||||
|
|
||||||
|
# Builds
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
Sand.Core\artifacts
|
||||||
|
Sand.App\artifacts
|
||||||
|
|||||||
16
.vscode/launch.json
vendored
16
.vscode/launch.json
vendored
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
5
Directory.Build.props
Normal file
5
Directory.Build.props
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<DefaultItemExcludes>$(DefaultItemExcludes);**\artifacts\**</DefaultItemExcludes>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
95
README.md
95
README.md
@ -1,55 +1,60 @@
|
|||||||
# ***Falling Sand Simulation Concept in Python***
|
# Sand C#
|
||||||
|
|
||||||
mostly a concept in python for falling sand simulation
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Main Features
|
## Repo layout
|
||||||
|
|
||||||
- Particle Physics
|
- `Sand.Core/`, `Sand.App/`, `Sand.Tests/`: production dense backend, app shell, and regression tests
|
||||||
- Gravity and wind effects
|
- `Sand.ChunkPrototype/`, `Sand.ChunkPrototype.Tests/`: experimental chunk backend and its tests
|
||||||
- Temperature dynamics
|
- `Sand.Benchmarks/`: dense/chunk benchmark runner with app-sized and snapshot modes
|
||||||
- State transitions (melting, freezing, evaporation)
|
- `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
|
||||||
|
|
||||||
- Particle Interactions
|
## Current status
|
||||||
- Collision detection
|
|
||||||
- Chemical reactions (e.g., water + sand = mud)
|
|
||||||
- Heat transfer between particles
|
|
||||||
|
|
||||||
- Special Effects
|
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.
|
||||||
- Fire propagation sorta
|
|
||||||
- Smoke generation
|
|
||||||
- Liquid spreading
|
|
||||||
|
|
||||||
- Optimization Features
|
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.
|
||||||
- Spatial partitioning grid
|
|
||||||
- Dormant particle tracking
|
|
||||||
- Batch processing
|
|
||||||
- Static User Interface
|
|
||||||
|
|
||||||
### **Current Features**
|
Real app runs are the source of truth for chunk progress. Benchmarks support decisions, but they do not replace interactive testing.
|
||||||
|
|
||||||
| **Working** | **Partial** | **Not Working/Implemented** |
|
## Run
|
||||||
| ----------------- | ----------------------------- | --------------------------- |
|
|
||||||
| 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 | |
|
|
||||||
|
|
||||||
#### **Controls**
|
Dense app:
|
||||||
|
|
||||||
| Key | Action |
|
```powershell
|
||||||
| ------------------- | ------------------------------ |
|
Remove-Item Env:SAND_BACKEND -ErrorAction SilentlyContinue
|
||||||
| Z | Zoom window |
|
dotnet run --project .\Sand.App\Sand.App.csproj -c Release
|
||||||
| ESC | Exit Program |
|
```
|
||||||
| C | Clear Screen |
|
|
||||||
| Space | Pause Simulation |
|
Chunk app:
|
||||||
| Mouse 1 {Left} | Spawn Particle with brush size |
|
|
||||||
| Mouse 3 {Right} | Erase Particles at cursor |
|
```powershell
|
||||||
| Mouse 2 {Middle} | Spawn Particle at cursor |
|
$env:SAND_BACKEND='chunk'
|
||||||
| Mouse Wheel Up/Down | Change Brush size |
|
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
|
||||||
|
```
|
||||||
|
|||||||
172
ROADMAP.md
Normal file
172
ROADMAP.md
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# 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
|
||||||
|
- `[-]` recent mixed-scene live runs show healthier attempt-to-move ratios and low render cost, so the remaining work is mostly true sim/runtime cost rather than frame catch-up collapse
|
||||||
|
- `[ ]` 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.
|
||||||
|
- Recent mixed-scene runs also show the overload policy is holding: frame collapse is no longer primarily coming from runaway catch-up steps, but from the simulation work itself.
|
||||||
|
- 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.
|
||||||
39
Sand.App/AppSimulationFrameStats.cs
Normal file
39
Sand.App/AppSimulationFrameStats.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
562
Sand.App/ChunkPrototypeSimulationBackend.cs
Normal file
562
Sand.App/ChunkPrototypeSimulationBackend.cs
Normal file
@ -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<string, PrototypeParticle> _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<byte> 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<string, PrototypeParticle> BuildParticleProfiles(ParticleLibrary library)
|
||||||
|
{
|
||||||
|
var idLookup = new Dictionary<string, ushort>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++)
|
||||||
|
{
|
||||||
|
var definition = library.GetDefinition(typeId);
|
||||||
|
idLookup[definition.Id] = typeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = new Dictionary<string, PrototypeParticle>(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<string, ushort> 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<string, ushort> 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Sand.App/CoreSimulationBackend.cs
Normal file
73
Sand.App/CoreSimulationBackend.cs
Normal file
@ -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<byte> 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);
|
||||||
|
}
|
||||||
24
Sand.App/ISimulationBackend.cs
Normal file
24
Sand.App/ISimulationBackend.cs
Normal file
@ -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<byte> 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);
|
||||||
|
}
|
||||||
9
Sand.App/Program.cs
Normal file
9
Sand.App/Program.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Sand.App;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
SandApp.Run();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
Sand.App/Sand.App.csproj
Normal file
22
Sand.App/Sand.App.csproj
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sand.Core\Sand.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\Sand.ChunkPrototype\Sand.ChunkPrototype.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Raylib-cs" Version="7.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\python\src\part\**\*.json" Link="Content\part\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
48
Sand.App/SandApp.Models.cs
Normal file
48
Sand.App/SandApp.Models.cs
Normal file
@ -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<string, List<ParticleDef>> 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<bool> Get, Action Toggle);
|
||||||
|
|
||||||
|
internal readonly record struct PendingWorldAction(
|
||||||
|
bool Active,
|
||||||
|
bool IsErase,
|
||||||
|
int SimX,
|
||||||
|
int SimY,
|
||||||
|
Vector2 Mouse);
|
||||||
252
Sand.App/SandApp.Rendering.cs
Normal file
252
Sand.App/SandApp.Rendering.cs
Normal file
@ -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<SettingItem> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
Sand.App/SandApp.Sidebar.cs
Normal file
108
Sand.App/SandApp.Sidebar.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
332
Sand.App/SandApp.Update.cs
Normal file
332
Sand.App/SandApp.Update.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
Sand.App/SandApp.cs
Normal file
179
Sand.App/SandApp.cs
Normal file
@ -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<string, List<ParticleDef>> 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<string, List<ParticleDef>>
|
||||||
|
{
|
||||||
|
["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<string, List<ParticleDef>> 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<string, List<ParticleDef>>
|
||||||
|
{
|
||||||
|
["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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Sand.Benchmarks.slnx
Normal file
5
Sand.Benchmarks.slnx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="Sand.Benchmarks/Sand.Benchmarks.csproj" />
|
||||||
|
<Project Path="Sand.ChunkPrototype/Sand.ChunkPrototype.csproj" />
|
||||||
|
<Project Path="Sand.Core/Sand.Core.csproj" />
|
||||||
|
</Solution>
|
||||||
869
Sand.Benchmarks/Program.cs
Normal file
869
Sand.Benchmarks/Program.cs
Normal file
@ -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<int> 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<int> 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<AppWorkload> 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<AppWorkload> 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<AppWorkload> 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<AppWorkload> BuildChunkScaleWorkloads() => BuildDenseScaleWorkloads();
|
||||||
|
|
||||||
|
IReadOnlyList<AppWorkload> 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<AppWorkload> 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<SandSimulation> SetupDense,
|
||||||
|
Action<PrototypeSparseSandAdapter, ParticleLibrary?> SetupChunk,
|
||||||
|
Action<SandSimulation> TickDense,
|
||||||
|
Action<PrototypeSparseSandAdapter, ParticleLibrary?> 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);
|
||||||
46
Sand.Benchmarks/README.md
Normal file
46
Sand.Benchmarks/README.md
Normal file
@ -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
|
||||||
17
Sand.Benchmarks/Sand.Benchmarks.csproj
Normal file
17
Sand.Benchmarks/Sand.Benchmarks.csproj
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\\Sand.Core\\Sand.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\\Sand.ChunkPrototype\\Sand.ChunkPrototype.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\\python\\src\\part\\**\\*.json" Link="Content\\part\\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
168
Sand.ChunkPrototype.App/Program.cs
Normal file
168
Sand.ChunkPrototype.App/Program.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
17
Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj
Normal file
17
Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Raylib-cs" Version="7.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sand.ChunkPrototype\Sand.ChunkPrototype.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
525
Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs
Normal file
525
Sand.ChunkPrototype.Tests/PrototypeSparseSandAdapterTests.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj
Normal file
23
Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sand.ChunkPrototype\Sand.ChunkPrototype.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
31
Sand.ChunkPrototype/ChunkActivityTracker.cs
Normal file
31
Sand.ChunkPrototype/ChunkActivityTracker.cs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
internal sealed class ChunkActivityTracker
|
||||||
|
{
|
||||||
|
private readonly List<ChunkCoord> _activeChunks = new();
|
||||||
|
private readonly List<ChunkCoord> _sleepingChunks = new();
|
||||||
|
|
||||||
|
public (IReadOnlyList<ChunkCoord> ActiveChunks, int SleepingChunks) Build(IReadOnlyDictionary<ChunkCoord, ChunkCellPage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
346
Sand.ChunkPrototype/ChunkCellPage.cs
Normal file
346
Sand.ChunkPrototype/ChunkCellPage.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
3
Sand.ChunkPrototype/ChunkCoord.cs
Normal file
3
Sand.ChunkPrototype/ChunkCoord.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
internal readonly record struct ChunkCoord(int X, int Y);
|
||||||
96
Sand.ChunkPrototype/ChunkFieldPage.cs
Normal file
96
Sand.ChunkPrototype/ChunkFieldPage.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
9
Sand.ChunkPrototype/ChunkResidencyConfig.cs
Normal file
9
Sand.ChunkPrototype/ChunkResidencyConfig.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
9
Sand.ChunkPrototype/ChunkStepScheduler.cs
Normal file
9
Sand.ChunkPrototype/ChunkStepScheduler.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
internal sealed class ChunkStepScheduler
|
||||||
|
{
|
||||||
|
private readonly ChunkActivityTracker _activityTracker = new();
|
||||||
|
|
||||||
|
public (IReadOnlyList<ChunkCoord> ActiveChunks, int SleepingChunks) BuildSchedule(IReadOnlyDictionary<ChunkCoord, ChunkCellPage> pages) =>
|
||||||
|
_activityTracker.Build(pages);
|
||||||
|
}
|
||||||
27
Sand.ChunkPrototype/ChunkStepStats.cs
Normal file
27
Sand.ChunkPrototype/ChunkStepStats.cs
Normal file
@ -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);
|
||||||
160
Sand.ChunkPrototype/ChunkVisualTracker.cs
Normal file
160
Sand.ChunkPrototype/ChunkVisualTracker.cs
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
internal static class ChunkVisualTracker
|
||||||
|
{
|
||||||
|
public static long RenderDirtyPages(
|
||||||
|
byte[] destination,
|
||||||
|
int worldWidth,
|
||||||
|
IReadOnlyDictionary<ChunkCoord, ChunkCellPage> cellPages,
|
||||||
|
IReadOnlyDictionary<ChunkCoord, ChunkFieldPage> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
254
Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs
Normal file
254
Sand.ChunkPrototype/PrototypeChunkResidencyWorld.cs
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
using AdvChkSys.Chunk;
|
||||||
|
using AdvChkSys.Manager;
|
||||||
|
|
||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
public sealed class PrototypeChunkResidencyWorld
|
||||||
|
{
|
||||||
|
private readonly ChunkManager2D<byte> _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<byte>(
|
||||||
|
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<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
122
Sand.ChunkPrototype/PrototypeParticle.cs
Normal file
122
Sand.ChunkPrototype/PrototypeParticle.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
10
Sand.ChunkPrototype/PrototypeParticleType.cs
Normal file
10
Sand.ChunkPrototype/PrototypeParticleType.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Sand.ChunkPrototype;
|
||||||
|
|
||||||
|
public enum PrototypeParticleType : byte
|
||||||
|
{
|
||||||
|
Empty = 0,
|
||||||
|
Sand = 1,
|
||||||
|
Water = 2,
|
||||||
|
Steam = 3,
|
||||||
|
Wall = 4,
|
||||||
|
}
|
||||||
401
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs
Normal file
401
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Cells.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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<byte> 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<ChunkCoord>();
|
||||||
|
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<byte> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1061
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs
Normal file
1061
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Movement.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
472
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs
Normal file
472
Sand.ChunkPrototype/PrototypeSparseSandAdapter.Step.cs
Normal file
@ -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<ChunkCoord> 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<ChunkCoord> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
183
Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs
Normal file
183
Sand.ChunkPrototype/PrototypeSparseSandAdapter.cs
Normal file
@ -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<ushort, PrototypeParticle> _particleProfiles = new();
|
||||||
|
private readonly Dictionary<ChunkCoord, ChunkCellPage> _cellPages = new();
|
||||||
|
private readonly Dictionary<ChunkCoord, ChunkFieldPage> _fieldPages = new();
|
||||||
|
private readonly HashSet<ChunkCoord> _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<KeyValuePair<(int X, int Y), PrototypeParticle>> ParticleEntries
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var particles = new List<KeyValuePair<(int X, int Y), PrototypeParticle>>(_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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Sand.ChunkPrototype/Sand.ChunkPrototype.csproj
Normal file
12
Sand.ChunkPrototype/Sand.ChunkPrototype.csproj
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\\advchksys\\src\\AdvChkSys\\AdvChkSys.csproj" />
|
||||||
|
<ProjectReference Include="..\\Sand.Core\\Sand.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
8
Sand.Core/IParticleLibrary.cs
Normal file
8
Sand.Core/IParticleLibrary.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public interface IParticleLibrary
|
||||||
|
{
|
||||||
|
IReadOnlyList<ParticleDef> Definitions { get; }
|
||||||
|
ushort GetTypeId(string id);
|
||||||
|
ParticleDef GetDefinition(ushort typeId);
|
||||||
|
}
|
||||||
6
Sand.Core/ISimulationAccelerator.cs
Normal file
6
Sand.Core/ISimulationAccelerator.cs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public interface ISimulationAccelerator
|
||||||
|
{
|
||||||
|
void Step(SandSimulation simulation, float dt);
|
||||||
|
}
|
||||||
27
Sand.Core/ParticleBalanceProfile.cs
Normal file
27
Sand.Core/ParticleBalanceProfile.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
10
Sand.Core/ParticleBehaviorKind.cs
Normal file
10
Sand.Core/ParticleBehaviorKind.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public enum ParticleBehaviorKind : byte
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
Fire = 1,
|
||||||
|
BurningWood = 2,
|
||||||
|
Ember = 3,
|
||||||
|
Plasma = 4,
|
||||||
|
}
|
||||||
63
Sand.Core/ParticleDef.cs
Normal file
63
Sand.Core/ParticleDef.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
8
Sand.Core/ParticleKind.cs
Normal file
8
Sand.Core/ParticleKind.cs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public enum ParticleKind : byte
|
||||||
|
{
|
||||||
|
Solid = 1,
|
||||||
|
Liquid = 2,
|
||||||
|
Gas = 3,
|
||||||
|
}
|
||||||
49
Sand.Core/ParticleLibrary.cs
Normal file
49
Sand.Core/ParticleLibrary.cs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
|
||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public sealed class ParticleLibrary : IParticleLibrary
|
||||||
|
{
|
||||||
|
private readonly IReadOnlyList<ParticleDef> _definitions;
|
||||||
|
private readonly Dictionary<string, ushort> _typeIds;
|
||||||
|
|
||||||
|
public ParticleLibrary(IReadOnlyList<ParticleDef> definitions)
|
||||||
|
{
|
||||||
|
if (definitions.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("At least one particle definition is required.", nameof(definitions));
|
||||||
|
}
|
||||||
|
|
||||||
|
_definitions = new ReadOnlyCollection<ParticleDef>(definitions.ToArray());
|
||||||
|
_typeIds = new Dictionary<string, ushort>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
for (var index = 0; index < definitions.Count; index++)
|
||||||
|
{
|
||||||
|
_typeIds[definitions[index].Id] = checked((ushort)(index + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ParticleDef> 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
255
Sand.Core/ParticleLibraryLoader.cs
Normal file
255
Sand.Core/ParticleLibraryLoader.cs
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public static class ParticleLibraryLoader
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> 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<string, JsonObject>(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<ParticleDef>(orderedIds.Length);
|
||||||
|
|
||||||
|
foreach (var id in orderedIds)
|
||||||
|
{
|
||||||
|
definitions.Add(ParseDefinition(id, rawDefinitions[id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownIds = new HashSet<string>(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<string> 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<string>();
|
||||||
|
|
||||||
|
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<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float GetFloat(JsonObject source, string key, float defaultValue)
|
||||||
|
{
|
||||||
|
if (source[key] is null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source[key]!.GetValue<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetInt(JsonObject source, string key, int defaultValue)
|
||||||
|
{
|
||||||
|
if (source[key] is null)
|
||||||
|
{
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source[key]!.GetValue<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float? GetNullableFloat(JsonObject source, string key)
|
||||||
|
{
|
||||||
|
if (source[key] is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source[key]!.GetValue<float>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>(),
|
||||||
|
(byte)array[1]!.GetValue<int>(),
|
||||||
|
(byte)array[2]!.GetValue<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>(),
|
||||||
|
(byte)array[1]!.GetValue<int>(),
|
||||||
|
(byte)array[2]!.GetValue<int>());
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>().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<float>())
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Sand.Core/ParticleRuntimeProfile.cs
Normal file
7
Sand.Core/ParticleRuntimeProfile.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public sealed class ParticleRuntimeProfile
|
||||||
|
{
|
||||||
|
public required ParticleDef Definition { get; init; }
|
||||||
|
public required ParticleBalanceProfile Balance { get; init; }
|
||||||
|
}
|
||||||
146
Sand.Core/ParticleRuntimeProfileBuilder.cs
Normal file
146
Sand.Core/ParticleRuntimeProfileBuilder.cs
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Sand.Core/Rgb24.cs
Normal file
3
Sand.Core/Rgb24.cs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public readonly record struct Rgb24(byte R, byte G, byte B);
|
||||||
9
Sand.Core/Sand.Core.csproj
Normal file
9
Sand.Core/Sand.Core.csproj
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
544
Sand.Core/SandSimulation.CellsAndForces.cs
Normal file
544
Sand.Core/SandSimulation.CellsAndForces.cs
Normal file
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
799
Sand.Core/SandSimulation.Infrastructure.cs
Normal file
799
Sand.Core/SandSimulation.Infrastructure.cs
Normal file
@ -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<int, int> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
493
Sand.Core/SandSimulation.Movement.cs
Normal file
493
Sand.Core/SandSimulation.Movement.cs
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
266
Sand.Core/SandSimulation.Painting.cs
Normal file
266
Sand.Core/SandSimulation.Painting.cs
Normal file
@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
299
Sand.Core/SandSimulation.Step.cs
Normal file
299
Sand.Core/SandSimulation.Step.cs
Normal file
@ -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<byte> BuildRgbFrame()
|
||||||
|
{
|
||||||
|
UpdateCachedVisualBuffers();
|
||||||
|
return _rgbBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReadOnlySpan<byte> BuildRgbaFrame()
|
||||||
|
{
|
||||||
|
UpdateCachedVisualBuffers();
|
||||||
|
FrameStats.FrameBuildBytesTouched = (long)_rgbaBuffer.Length;
|
||||||
|
FrameStats.RenderTimeMicroseconds = 0;
|
||||||
|
return _rgbaBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void BuildRgbaFrame(Span<byte> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
609
Sand.Core/SandSimulation.Thermal.cs
Normal file
609
Sand.Core/SandSimulation.Thermal.cs
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
347
Sand.Core/SandSimulation.cs
Normal file
347
Sand.Core/SandSimulation.cs
Normal file
@ -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];
|
||||||
|
}
|
||||||
|
|
||||||
39
Sand.Core/SimulationFrameStats.cs
Normal file
39
Sand.Core/SimulationFrameStats.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
25
Sand.Core/SimulationSettings.cs
Normal file
25
Sand.Core/SimulationSettings.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
7
Sand.Core/SimulationStorageMode.cs
Normal file
7
Sand.Core/SimulationStorageMode.cs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
public enum SimulationStorageMode
|
||||||
|
{
|
||||||
|
Dense = 0,
|
||||||
|
ChunkPrototype = 1,
|
||||||
|
}
|
||||||
9
Sand.Core/ToolProfile.cs
Normal file
9
Sand.Core/ToolProfile.cs
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
namespace Sand.Core;
|
||||||
|
|
||||||
|
internal readonly record struct ToolProfile(
|
||||||
|
string Id,
|
||||||
|
int RadiusCells,
|
||||||
|
float Strength,
|
||||||
|
float Falloff,
|
||||||
|
float Turbulence,
|
||||||
|
string[] Affects);
|
||||||
8
Sand.Experimental.slnx
Normal file
8
Sand.Experimental.slnx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="advchksys/src/AdvChkSys/AdvChkSys.csproj" />
|
||||||
|
<Project Path="advchksys/src/AdvChkSys.Tests/AdvChkSys.Tests.csproj" />
|
||||||
|
<Project Path="Sand.ChunkPrototype/Sand.ChunkPrototype.csproj" />
|
||||||
|
<Project Path="Sand.ChunkPrototype.App/Sand.ChunkPrototype.App.csproj" />
|
||||||
|
<Project Path="Sand.ChunkPrototype.Tests/Sand.ChunkPrototype.Tests.csproj" />
|
||||||
|
<Project Path="Sand.Core/Sand.Core.csproj" />
|
||||||
|
</Solution>
|
||||||
31
Sand.Tests/ParticleLibraryLoaderTests.cs
Normal file
31
Sand.Tests/ParticleLibraryLoaderTests.cs
Normal file
@ -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");
|
||||||
|
}
|
||||||
28
Sand.Tests/Sand.Tests.csproj
Normal file
28
Sand.Tests/Sand.Tests.csproj
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="8.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Xunit" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Sand.Core\Sand.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="..\python\src\part\**\*.json" Link="Content\part\%(RecursiveDir)%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
920
Sand.Tests/SandSimulationTests.cs
Normal file
920
Sand.Tests/SandSimulationTests.cs
Normal file
@ -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 });
|
||||||
|
}
|
||||||
62
Sand.sln
Normal file
62
Sand.sln
Normal file
@ -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
|
||||||
5
Sand.slnx
Normal file
5
Sand.slnx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="Sand.App/Sand.App.csproj" />
|
||||||
|
<Project Path="Sand.Core/Sand.Core.csproj" />
|
||||||
|
<Project Path="Sand.Tests/Sand.Tests.csproj" />
|
||||||
|
</Solution>
|
||||||
3
advchksys/CHANGELOG.md
Normal file
3
advchksys/CHANGELOG.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
161
advchksys/README.md
Normal file
161
advchksys/README.md
Normal file
@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
237
advchksys/build.bat
Normal file
237
advchksys/build.bat
Normal file
@ -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
|
||||||
2
advchksys/scripts/track_progress/CHANGELOG.md
Normal file
2
advchksys/scripts/track_progress/CHANGELOG.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
207
advchksys/scripts/track_progress/README.md
Normal file
207
advchksys/scripts/track_progress/README.md
Normal file
@ -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
|
||||||
|
```
|
||||||
1
advchksys/scripts/track_progress/__init__.py
Normal file
1
advchksys/scripts/track_progress/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# This file makes the directory a Python package
|
||||||
103
advchksys/scripts/track_progress/changelog_generator.py
Normal file
103
advchksys/scripts/track_progress/changelog_generator.py
Normal file
@ -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
|
||||||
121
advchksys/scripts/track_progress/code_stats.py
Normal file
121
advchksys/scripts/track_progress/code_stats.py
Normal file
@ -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
|
||||||
87
advchksys/scripts/track_progress/config.py
Normal file
87
advchksys/scripts/track_progress/config.py
Normal file
@ -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
|
||||||
317
advchksys/scripts/track_progress/feature_markdown.py
Normal file
317
advchksys/scripts/track_progress/feature_markdown.py
Normal file
@ -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(
|
||||||
|
"""<style>
|
||||||
|
.feature-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-left: 5px solid #ccc;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.completed {
|
||||||
|
border-left-color: #28a745;
|
||||||
|
background-color: #f0fff0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.in_progress {
|
||||||
|
border-left-color: #007bff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.planned {
|
||||||
|
border-left-color: #6c757d;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-status {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-date {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
238
advchksys/scripts/track_progress/git_analyzer.py
Normal file
238
advchksys/scripts/track_progress/git_analyzer.py
Normal file
@ -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
|
||||||
37
advchksys/scripts/track_progress/progress_config.json
Normal file
37
advchksys/scripts/track_progress/progress_config.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
advchksys/scripts/track_progress/requirements.txt
Normal file
16
advchksys/scripts/track_progress/requirements.txt
Normal file
@ -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
|
||||||
33
advchksys/scripts/track_progress/roadmap_generator.py
Normal file
33
advchksys/scripts/track_progress/roadmap_generator.py
Normal file
@ -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
|
||||||
55
advchksys/scripts/track_progress/scripts/SCRIPTS.md
Normal file
55
advchksys/scripts/track_progress/scripts/SCRIPTS.md
Normal file
@ -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`
|
||||||
133
advchksys/scripts/track_progress/scripts/build_with_nuitka.py
Normal file
133
advchksys/scripts/track_progress/scripts/build_with_nuitka.py
Normal file
@ -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())
|
||||||
@ -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
|
||||||
@ -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
|
||||||
@ -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)
|
||||||
@ -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
|
||||||
285
advchksys/scripts/track_progress/scripts/setup_install.py
Normal file
285
advchksys/scripts/track_progress/scripts/setup_install.py
Normal file
@ -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()
|
||||||
239
advchksys/scripts/track_progress/template_engine.py
Normal file
239
advchksys/scripts/track_progress/template_engine.py
Normal file
@ -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
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
# {{ project_name }} Changelog
|
||||||
|
|
||||||
|
Last updated: {{ current_date }}
|
||||||
|
|
||||||
|
{% for entry in changelog_entries %}
|
||||||
|
{{ entry }}
|
||||||
|
{% endfor %}
|
||||||
@ -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 %}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.feature-card {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border-left: 5px solid #ccc;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.completed {
|
||||||
|
border-left-color: #28a745;
|
||||||
|
background-color: #f0fff0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.in_progress {
|
||||||
|
border-left-color: #007bff;
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card.planned {
|
||||||
|
border-left-color: #6c757d;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-status {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-date {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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 %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user