Compare commits

...
30 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 4.8 46f3931f85 Lighting: lighter night floor (0.18 -> 0.24)
CI / build-test (push) Successful in 1m16s
Night was a touch too dark; raise the ambient moonlight floor a couple points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:33:20 +03:00
Leonid Pershin 96e19c7c61 Lighting: directional sun shadows from the day/night sun angle
DayNight.SunShadow returns a cell offset opposite the sun's east-west position, longest near sunrise/sunset (low sun) and zero at noon/night, tilted slightly south so shadows fall in front of occluders. LightmapBuilder gains a directional shadow pass (MaxShadowCells length cap, SunShadowStrength darkening); LightmapSystem feeds it the current sun shadow each rebuild. Tests cover SunShadow's day-arc behaviour and the builder's directional darkening.
2026-06-14 07:04:11 +03:00
Leonid Pershin 38bdfbbb81 Enhance Calendar and Climate systems with start day offsets
CI / build-test (push) Successful in 1m13s
- Added a `startDay` parameter to the `Calendar` class to allow for fractional day offsets at clock initialization, enabling more flexible time settings.
- Updated `TotalDays` calculation to include the `startDay` offset.
- Introduced `StartDayOfYear` in `ClimateSettings` to shift the seasonal phase, allowing worlds to begin at a specific time of year.
- Adjusted `YearProgress` and `Year` calculations in the `Climate` class to account for the new `StartDayOfYear`.
- Added unit tests to verify the functionality of the new start day features in both `Calendar` and `Climate` classes.
2026-06-13 06:55:09 +03:00
Leonid Pershin 08381703f7 Add WorldCenter property to CameraState for effective camera positioning
CI / build-test (push) Successful in 1m19s
Enhanced the CameraState struct with a new WorldCenter property that calculates the effective position of the camera after bounds-clamping. This property is intended to be used for zoom-to-cursor functionality, ensuring that the repositioning aligns with what is rendered.

Added unit tests to verify that WorldCenter reflects the unclamped camera position and correctly accounts for bounds clamping, distinguishing it from the raw camera position.

Tests: WorldCenter_EqualsUnclampedCameraPosition, WorldCenter_ReflectsBoundsClamp_UnlikeRawPosition.
2026-06-13 05:21:24 +03:00
Leonid PershinandClaude Fable 5 f382fc98ea Net: heartbeat liveness, protocol version, message cap, replication reset
CI / build-test (push) Successful in 1m12s
Robustness pass over MrGameEng.Net:
- WebSocketServer heartbeats each connection (configurable interval/timeout)
  and drops peers idle past the timeout — detects half-open TCP that vanished
  without a close frame. Tracks last-activity per connection.
- Reassembled messages capped (server and client) so a peer cannot exhaust
  memory with an oversized fragmented message.
- Replication snapshots carry a protocol-version byte; a client receiving a
  mismatched version drops the message instead of decoding garbage, and a
  truncated snapshot is ignored without throwing.
- ReplicationClient.Clear() deletes all replicated entities, so clients can
  wipe stale state before reconnecting.

Tests: heartbeat healthy-survives / silent-peer-dropped, protocol-version
mismatch, truncated snapshot, replication clear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:07 +03:00
Leonid PershinandClaude Opus 4.8 d360093be1 Add Trapezoidal suitability function and corresponding tests
CI / build-test (push) Successful in 1m10s
Introduced a new Trapezoid method in the Suitability class to model suitability with a hard tolerance band and a plateau. The method returns suitability values based on defined limits and optimal ranges. Added comprehensive unit tests to validate the functionality, including edge cases for flat plateaus, linear ramps, and hard limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 04:34:32 +03:00
Leonid PershinandClaude Opus 4.8 d044cafad9 Regex tooling: formula gene grouping, content patches, def validation
CI / build-test (push) Successful in 1m12s
Three regex-powered content tools (phase G5):

- Formula group functions gsum/gcount/gavg/gmin/gmax('regex') aggregate
  over every context variable whose name matches the pattern, e.g.
  gsum('leaf_.*'). Adds string literals to the formula grammar and an
  IFormulaContext.ResolveMatching hook; GenomeContext enumerates matching
  genes, so a trait can sum/average a gene group.
- DefDatabase content patches: a { "type": "Patch", patches:[{ defType,
  match (regex on defName), set:{fields} }] } file sets fields on every
  matching raw def before resolution — mods patch Core in bulk.
- DefDatabase.RegisterValidator(typeKey, field, regex): load-time check
  that a string field matches a pattern, throwing otherwise (naming/format
  conventions).

Covered by FormulaTests (group aggregation, composition, bad calls) and
DefDatabaseTests (patch set/match/unknown-type, validator pass/reject).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:59:36 +03:00
Leonid PershinandClaude Opus 4.8 ead22517ee Genetics: GenomeTemplate (per-organism gene allotment) + breed override
CI / build-test (push) Successful in 1m17s
Adds the species/organism layer on top of the gene foundation.
GenomeTemplate carries which GeneDefs an individual has plus the
per-organism base value and spread its alleles are drawn around (and an
optional discrete-variant override), so the same shared GeneDef expresses
different centres for different species — Generate() draws an individual,
Registry() feeds breeding and trait computation.

Genome.Breed gains an optional mutationChance that overrides every gene's
fixed MutationChance, so a caller can drive mutation from an evolvable
trait. Allele sampling (numeric spread+clamp, weighted discrete pick) is
factored into a shared GeneSampling used by both Generate paths.

Covered by GenomeTemplateTests (per-species centres, registry-driven
breeding, mutation override on/off).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:51:02 +03:00
Leonid PershinandClaude Opus 4.8 bc18db5df6 Add gene foundation: GeneDef, Genome, trait phenotype (Content)
CI / build-test (push) Successful in 1m17s
The organism-agnostic core of the gene system, built on the formula
engine. GeneDef is a def describing a gene's kind (numeric/discrete),
allele generation range/spread, mutation, variant distribution and its
effects on named traits as formulas. Genome is a managed, variable-
composition map (geneId -> Allele pair): generated from a gene set, bred
meiotically with per-gene mutation, expressed to a phenotype (numeric
mean / discrete lower-allele dominance); open composition allows hybrids.
Phenotype.Compute aggregates each gene's effect formulas into a trait
map (variable `value` = the gene's expressed phenotype, other gene ids
and an environment context resolve too), so systems read traits, never
genes.

Nothing here is species-specific. Def JSON now supports string-named
enums (JsonStringEnumConverter) so a gene's kind reads as "Discrete".
Covered by GeneticsTests (generation/expression/breeding/traits) and
GeneDefLoadTests (GeneDef through the real DefDatabase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:43:01 +03:00
Leonid PershinandClaude Fable 5 3438ed77f6 Add MrGameEng.Net: WebSocket transport + delta component replication
CI / build-test (push) Successful in 1m16s
Browsers can't speak UDP, so WebSocket is the engine's one transport.
The server side is a dependency-free RFC 6455 implementation over
TcpListener (handshake, frame codec with masking and fragmentation,
ping/pong — unit-tested against the RFC example vectors); the client
wraps ClientWebSocket, which works on desktop and maps to the browser
WebSocket in Blazor WASM. Both sit behind the poll-based INetConnection
so simulation systems drain messages from their own thread; client
sends are chained fire-and-forget (no blocking — wasm-safe).

Replication is server-authoritative: games register unmanaged component
types in a ReplicationSchema (same order both sides, up to 32 types),
ReplicationServer snapshots entities carrying NetId once per send and
ships each connection only the components that changed since its last
snapshot — a reliable ordered transport needs no acks for deltas. New
connections receive the full state through the same path; despawns are
tracked by set difference. ReplicationClient applies snapshots to a
local EntityStore and raises EntitySpawned so the game can decorate
replicated entities with presentation components.

Covered by 16 tests including a real loopback exchange between
WebSocketClient and WebSocketServer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:35:57 +03:00
Leonid PershinandClaude Opus 4.8 10898b08a0 Add formula engine: data-driven expression evaluator (Core)
CI / build-test (push) Successful in 1m19s
Keystone for the gene system. A Formula compiles a string from a def
(lexer -> recursive-descent parser -> tree of Func<IFormulaContext,float>)
once, then evaluates allocation-free against a variable context.

Supports + - * / %, comparisons, && || !, ternary, the constants
pi/tau/e, and functions abs sign floor ceil round sqrt exp log sin cos
tan min max pow clamp lerp step. Deterministic and side-effect-free so
gene-effect formulas stay pure. Covered by FormulaTests (parsing,
precedence, functions, logic/ternary, variables, errors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:26:06 +03:00
Leonid PershinandClaude Fable 5 2ac074004a Split the platform out of Core: new Host library + HeadlessHost
CI / build-test (push) Successful in 1m24s
Core now depends only on Friflo.Engine.ECS — no MonoGame, no platform.
The windowed MonoGame host moves to the new MrGameEng.Host library:
GameHost, GameHostOptions, visual scene transitions (OverlayTransition,
Transitions.Fade/Wipe, TransitionRenderer) and the Input feature
(namespace MrGameEng.Input is unchanged). Transition timing stays in
Core (SceneManager exposes ActiveTransition/TransitionCoverage/
TransitionPhase; the host draws the overlay). EngineContext loses its
GraphicsDevice property: hosts publish the device as a service and
graphics code reads it via context.GetGraphicsDevice() in Graphics.

Core gains HeadlessHost: a fixed-timestep loop without a window or GPU
(Tick/RunTicks, Run with wall-clock pacing and lag resync) for dedicated
servers, batch simulation and tests. Graphics and Audio now carry their
own MonoGame.Framework.DesktopGL reference instead of inheriting it
from Core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:04:34 +03:00
Leonid PershinandClaude Opus 4.8 79d406a9f4 Add 2D lightmap: occlusion shadows and point lights
CI / build-test (push) Successful in 1m14s
Extend the Lighting module with a per-cell lightmap. LightmapBuilder (pure,
tested) fills an ambient base, shades occluder cells, and adds point lights
that attenuate with distance and are blocked by occluders between source and
cell (grid-traced soft shadows). Lightmap holds the grid plus a greyscale
texture (Upload) and a bilinear SampleAt for the simulation; a PointLight
component places lights in the world. LightmapSystem rebuilds the grid a few
times a second (ambient from the day/night cycle with a night floor, occluders
from a game-supplied grid, point lights from the ECS); LightmapRenderSystem
multiplies the lightmap over the world after the sprite flush and under the
HUD. Wired via scene.UseLighting(...), sampled through Lighting.SampleAt. Docs
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 18:40:17 +03:00
Leonid PershinandClaude Opus 4.8 d0104df304 Add Suitability.Gaussian for optimum-tolerance bell curves
CI / build-test (push) Successful in 1m28s
A small AI helper computing how well a value matches a preferred optimum: a
Gaussian bell in [0,1], 1 at the optimum, e^-0.5 one tolerance away, over an
arbitrary input scale (unlike ResponseCurve's monotonic [0,1] shaping). The
building block for environment-suitability growth. Covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 17:52:11 +03:00
Leonid PershinandClaude Opus 4.8 a684c23cd9 Add Climate and day/night ambient lighting
CI / build-test (push) Successful in 1m11s
Climate (Core) layers a continuous seasonal-plus-daily temperature and the
current season over the Calendar, registered via context.UseClimate. A new
Lighting module (Graphics) adds DayNight: a daylight factor and ambient
color from the calendar's time of day, pushed into the renderer's new
AmbientLight (multiplied into world-space sprites only, so the scene
darkens at night while screen-space overlays stay readable) and exposed as
a sampleable SampleAt for the simulation. Both deterministic and GPU-free,
covered by tests; docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 16:45:49 +03:00
Leonid PershinandClaude Opus 4.8 f39ee9e787 Renderer: inset region UVs by half a texel to kill tile seams
CI / build-test (push) Successful in 1m22s
Region UVs ran exactly to the region edges, so at fractional zoom a tile's
edge fragments could sample past U1/V1 into the atlas's transparent padding
(point filtering), drawing thin black seams between tiles that flickered
while zooming. Inset the precomputed UVs by half a texel (texel-centre to
texel-centre) so sampling stays inside the region.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:44:44 +03:00
Leonid PershinandClaude Opus 4.8 4b91b60f38 Calendar: expose time of day (hour/minute)
CI / build-test (push) Successful in 1m7s
Add Hour, Minute and MinuteOfDay to Calendar, decomposing DayProgress into
a 24x60 in-game clock so games can show a date and time, not just the day
number. Covered by a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:09:58 +03:00
Leonid PershinandClaude Opus 4.8 44019a706e Inspector: keep field editing usable, clarify editable fields
CI / build-test (push) Successful in 1m7s
Split the inspector's revision into a list revision and a detail
revision. The periodic refresh now only bumps the list revision, and only
when the entity count actually changes, so the field-editor TextBox in the
details pane is no longer recreated (losing focus) twice a second while you
type. Stats and the performance tab update as plain text every frame
without rebuilding widgets.

Editable fields now render with a background, border and padding so they
read as inputs; read-only values and enum hints are muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:05:42 +03:00
Leonid PershinandClaude Opus 4.8 09dbdfad79 Add MrGameEng.Inspector: in-engine ECS debugger overlay
CI / build-test (push) Successful in 1m14s
Introduce a Chrome-DevTools-style ECS inspector in the UI library. The
headless core (ComponentReflector + EcsInspector) enumerates the store's
entities grouped by archetype, reflects a selected entity's components and
fields, and writes simple scalar edits (number/bool/enum) back through a
generic AddComponent — all unit-tested without a GPU. The Myra overlay
(EcsInspectorUi + InspectorSystems) adds a side panel with an
archetypes -> entities -> fields tree, an editable field view, a
world-pick mode with a selection highlight, and a renderer performance
tab; wired via scene.UseInspector(renderer), toggled with F1.

The UI library now references Graphics (for picking, renderer timings and
Transform2D/Sprite). Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:45:50 +03:00
Leonid PershinandClaude Opus 4.8 f791ee6c95 Add Calendar: in-game days layered over GameClock
CI / build-test (push) Successful in 1m11s
Introduce a Calendar in Core that turns scaled clock time into whole
in-game days plus a fraction-of-day, with a configurable SecondsPerDay.
Pausing or changing TimeScale slows or stops it automatically. Pure
read-side and deterministic, registered via context.UseCalendar(...),
mirroring GameSpeed. Covered by xUnit tests; docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:46:53 +03:00
Leonid PershinandClaude Opus 4.8 53659450a6 Add MrGameEng.WorldGen: deterministic procedural world generation
CI / build-test (push) Successful in 1m14s
Introduce a WorldGen module under Simulation with reusable helpers for
seed-based terrain heightmaps: PerlinNoise (deterministic 2D gradient
noise), FractalNoise (fBm over octaves), Falloff (island edge masks),
Heightmap (row-major grid with min-max normalize) and HeightmapGenerator
(settings to normalized heightmap). All deterministic from an integer
seed, allocation-free per sample, covered by xUnit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:23:45 +03:00
Leonid Pershin ef1111bcb6 Enhance audio management and documentation
CI / build-test (push) Successful in 1m10s
Added a MasterVolume property to the AudioManager for unified control over sound effects and music volume. Updated the Play method to incorporate MasterVolume adjustments. Enhanced documentation in CLAUDE.md and architecture.md to reflect changes in audio management and overall engine architecture. Introduced a new test project for audio functionalities in the solution file.
2026-06-12 08:41:39 +03:00
Leonid Pershin 3f3c0200a8 Refactor engine architecture and project structure
CI / build-test (push) Successful in 1m9s
Updated the organization of engine libraries to group features by architectural role rather than by individual feature. Enhanced documentation in CLAUDE.md and architecture.md to reflect these changes. Removed obsolete project references and added new modules for content management and simulation. Adjusted project files for MrGameEng.Graphics and MrGameEng.UI.Tests to align with the new structure.

This refactor aims to streamline the development process and improve clarity in the engine's architecture.
2026-06-12 07:47:12 +03:00
Leonid Pershin c30e2ce764 Remove obsolete project files for MrGameEng.AI, MrGameEng.Assets, MrGameEng.Atlases, and MrGameEng.Collisions modules. Introduce new AssetManager and related classes for asset loading and management, including support for texture atlases and mod definitions. Enhance mod loading capabilities with DefDatabase and LanguageManager for JSON-based definitions and localization. Implement a shelf packing algorithm for efficient texture atlas creation. 2026-06-12 07:47:04 +03:00
Leonid Pershin 1f87fb0b74 Update CI configuration to enable .NET roll-forward for testing on .NET 10 runtime
CI / build-test (push) Successful in 1m14s
2026-06-12 07:25:01 +03:00
Leonid Pershin fd6343bd09 @
CI / build-test (push) Failing after 1m8s
Add MrGameEng.AI utility-AI module; format codebase with CSharpier

New MrGameEng.AI module (ResponseCurve, Consideration, UtilityAction,
UtilityAi selector, Blackboard) plus CSharpier formatting applied across
the whole engine. Documents the CSharpier convention in CLAUDE.md.
@
2026-06-12 07:19:10 +03:00
Leonid PershinandClaude Fable 5 4ae730cafa CI: build with the .NET 10 SDK
CI / build-test (push) Failing after 1m17s
Roslyn 5.3.0 packages (asset-handles generator) ship analyzers built
against compiler 4.12+, which the SDK 8 toolchain cannot load (CS9057).
The target framework stays net8.0; CLAUDE.md documents the SDK floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:20:52 +03:00
Leonid PershinandClaude Fable 5 5d3c18de40 Add MrGameEng.Mods: mod loading, JSON defs and localization
CI / build-test (push) Failing after 1m8s
Mods are folders with About/About.json metadata; ModLoader resolves a
deterministic load order (dependencies first, ties alphabetical) and
later mods override earlier ones everywhere.

DefDatabase loads JSON def files ({ "type", "defs": [...] }) into
game-registered Def subclasses, with parent inheritance (own fields on
top of the parent's, nested values replaced whole), abstract parents
and full replacement of same-named defs by later mods.

LanguageManager loads Languages/<code>/*.json flat key-string maps,
switches language at runtime and falls back current -> default -> key.

ModContentTree merges one content folder across mods by relative path;
AtlasBuilder gains an explicit-sources Build overload so a merged
texture tree can be packed incrementally at game start.

The LittleSim game now ships its entire content as the Core mod,
demonstrating the module end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:51:17 +03:00
Leonid PershinandClaude Fable 5 501d81e19f Fix engine-wide code review findings
CI / build-test (push) Failing after 1m7s
Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:18:08 +03:00
Leonid Pershin 56f2a85478 Bump Roslyn packages to 5.3.0
CI / build-test (push) Failing after 1m4s
Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.Analyzers for
the asset-handles source generator.
2026-06-11 15:37:11 +03:00
180 changed files with 11980 additions and 1026 deletions
+7 -1
View File
@@ -10,10 +10,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Сборка требует SDK 10+: Roslyn-пакеты 5.3.0 (генератор ассет-хендлов)
# не загружаются компилятором из SDK 8. TargetFramework остаётся net8.0.
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
dotnet-version: 10.0.x
- name: Build
run: dotnet build MrGameEng.sln --configuration Release
- name: Test
# Тестхост собран под net8.0, а на раннере стоит только рантайм .NET 10
# (SDK 10). Roll-forward пускает net8.0-тестхост на рантайме 10.0.
env:
DOTNET_ROLL_FORWARD: Major
run: dotnet test MrGameEng.sln --configuration Release --no-build --verbosity normal
+81 -19
View File
@@ -10,32 +10,85 @@ Keep them up to date when architecture or conventions change.
## Solution layout
```
src/ MrGameEng.* engine libraries (one per functional area)
src/ MrGameEng.* engine libraries (grouped by role, not one-per-feature)
tests/ xUnit test projects, one per engine library
tools/ CLI tools (atlas packer)
docs/ architecture, conventions, roadmap (Russian)
```
Libraries are grouped by architectural role rather than split per feature, to avoid a
sprawl of two-file projects. A feature lives in a subfolder of its host library and
keeps its own `MrGameEng.<Feature>` namespace (namespaces are independent of the
assembly), so `using MrGameEng.Tilemaps;` etc. still resolve after a merge.
The engine has no sample project: the **LittleSim** game
(https://gitea.hsrv.site/mrleo1nid/LittleSim, this repo as the `engine/` submodule)
is the living showcase — new engine features are demonstrated there.
Engine modules: `Core` (game loop, ECS world, scenes, time), `Graphics` (custom batched
renderer, camera, sprites), `Input`, `Audio`, `Assets` (runtime loading, no content
pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles),
`Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`),
`Tilemaps` (code-built tile grids rendered through the batcher; `scene.UseTilemaps()`
after `UseRenderer2D()`), `Pathfinding` (grid A*/Dijkstra/BFS and flow fields over a
game-implemented `IPathGrid`; Core-only, owns no world data), `Collisions` (`Collider`
component, spatial hash rebuilt per tick, pairs/queries/raycast; `scene.UseCollisions()`
after movement systems), `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`),
`DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad).
Dependency rule: every module may depend only on `Core`; `Core` depends only on
MonoGame and Friflo.Engine.ECS. `Assets.Generator` is a netstandard2.0 analyzer.
Documented exceptions: Myra renders with its own SpriteBatch internally; `Atlases`
depends on `Graphics` (Texture2DRegion) and `Assets` (loader registration);
`Tilemaps` depends on `Graphics` (regions, layers, renderer);
`Collisions` depends on `Graphics` (Transform2D, RectF).
Engine libraries (each feature is a namespaced subfolder of its host):
- **`Core`** — the platform-free kernel: ECS world, scenes and transition timing, time
(`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over
the clock, `context.UseGameSpeed(...)`; `Calendar` turning scaled time into in-game days,
`context.UseCalendar(secondsPerDay)`; `Climate` — continuous seasonal/daily temperature
and season over the calendar, `context.UseClimate(settings)`), services, logging, and
**`HeadlessHost`** — a fixed-timestep loop without a window or GPU (dedicated servers,
batch simulation, tests). Depends only on Friflo.Engine.ECS — no MonoGame, no platform.
- **`Host`** — the windowed MonoGame host: `GameHost` (wraps `Game`, owns the window and
`GraphicsDeviceManager`, publishes `GraphicsDevice` as a service), visual scene
transitions (`OverlayTransition`, `Transitions.Fade/Wipe`, `TransitionRenderer`), plus
**Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in
`Host/Input/`). Nothing depends on `Host` except the game itself. → `Core`.
- **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps**
(`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher,
`scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting**
(`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving
`Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; plus a per-cell
**lightmap**`LightmapBuilder` (ambient × occlusion + point lights with grid-traced shadows),
`PointLight` component, multiplied over the world via `scene.UseLighting(...)`, sampleable with
`Lighting.SampleAt` for the simulation). → `Core`.
- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume`
(one knob for effects + music). → `Core`.
- **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime
loading, no MGCB pipeline; FontStash), **Atlases** (`MrGameEng.Atlases`: texture-atlas
builder + runtime loader, CLI wrapper in `tools/MrGameEng.AtlasTool`; StbImage), and
**Mods** (`MrGameEng.Mods`: mod discovery + load order from `About/About.json`, JSON
`Defs/` with parent inheritance and later-mod override, `Languages/<code>/`
localization, merged texture content trees; the game ships its content as the `Core`
mod). → `Core`, `Graphics` (Atlases needs `Texture2DRegion`).
- **`Simulation`** — deterministic gameplay primitives that own no world data:
**Pathfinding** (`MrGameEng.Pathfinding`: grid A*/Dijkstra/BFS and flow fields over a
game-implemented `IPathGrid`), **AI** (`MrGameEng.AI`: utility-AI primitives —
`ResponseCurve`, `Consideration<TContext>`, `UtilityAction<TContext>`,
`UtilityAi<TContext>` selector, `Blackboard`; generic over a game context), and
**Collisions** (`MrGameEng.Collisions`: `Collider` component, spatial hash rebuilt per
tick, pairs/queries/raycast, `scene.UseCollisions()` after movement systems).
`Core`, `Graphics` (Collisions needs `Transform2D`, `RectF`).
- **`Net`** — multiplayer building blocks, browser-compatible by design: a dependency-free
RFC 6455 WebSocket server over `TcpListener` (browsers can't speak UDP, so WebSocket is
the engine's one transport), a `ClientWebSocket`-based client (works in Blazor WASM),
both behind the poll-based `INetConnection`; server-authoritative component replication
(`ReplicationSchema` of unmanaged components, `ReplicationServer` sending per-connection
deltas — no acks needed over a reliable ordered transport, `ReplicationClient` applying
snapshots to a local store, `NetId`). → `Core`.
- **`UI`** — Myra integration (`scene.UseUI()` after `UseRenderer2D()`),
**DevConsole** (`MrGameEng.DevConsole`: in-game console capturing `Core.Log`,
`scene.UseDevConsole()` last in OnLoad) and **Inspector** (`MrGameEng.Inspector`: a
Chrome-DevTools-style ECS debugger overlay — entity tree by archetype, component/field
view with simple-field editing, world-pick with selection highlight, renderer perf tab;
`scene.UseInspector(renderer)`, toggle F1). → `Core`, `Graphics` (the inspector picks
entities and reads renderer timings; Myra renders with its own SpriteBatch internally).
- **`Assets.Generator`** — Roslyn source generator for typed asset handles; standalone
netstandard2.0 analyzer.
Dependency rule: a library may depend only on `Core` and `Graphics`; `Core` depends only
on Friflo.Engine.ECS (no MonoGame — the simulation must run headless). MonoGame is pulled
in by the platform/graphics libraries (`Host`, `Graphics`, `Audio`). Platform resources
(e.g. `GraphicsDevice`) are published by hosts as services in `EngineContext.Services`;
graphics code reaches the device via `context.GetGraphicsDevice()` (extension in
`Graphics`). Features grouped into one library share its package set (e.g. `Content`
carries both FontStash and StbImage) — keep optional/heavy deps (Myra, NVorbis) in their
own library so the rest of the engine stays free of them.
## Commands
@@ -44,6 +97,9 @@ dotnet build MrGameEng.sln
dotnet test MrGameEng.sln
```
Building requires the .NET 10+ SDK (Roslyn 5.3 packages used by the asset-handles
generator do not load under the SDK 8 compiler); the target framework stays net8.0.
## Architecture rules
- ECS-first: components are plain data (`struct` implementing `IComponent`),
@@ -62,8 +118,11 @@ dotnet test MrGameEng.sln
(textures via `Texture2D.FromFile`, fonts via FontStashSharp, ogg via NVorbis,
shaders precompiled by `dotnet-mgfxc` at build time). Game code references assets
only through generated typed handles (`AssetRef<T>`), never string paths.
- New engine functionality goes into the matching module, or a new
`MrGameEng.<Area>` library if it is a distinct area — never into `Core` by default.
- New engine functionality goes into the host library for its role (a namespaced
subfolder, e.g. a new render feature under `Graphics/`), not a fresh project. Add a
new `MrGameEng.<Area>` library only when the area is a genuinely new role or needs an
isolated heavy/optional dependency — never into `Core` by default, never a
two-file project per feature.
- Every public engine feature must be covered by tests where logic is testable
without a GPU, and demonstrated in the LittleSim game (the engine's showcase).
@@ -72,6 +131,9 @@ dotnet test MrGameEng.sln
- Nullable reference types enabled, warnings as errors, file-scoped namespaces.
- Public engine API requires XML doc comments (English).
- Tests: xUnit, named `Method_Scenario_Expectation`.
- Formatting: all C# code is formatted with **CSharpier**. Match its output —
run `csharpier format .` (or let the editor's format-on-save handle it) before
committing; never hand-format against it.
## Memory (echovault MCP)
-2
View File
@@ -1,5 +1,4 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
@@ -13,5 +12,4 @@
<PropertyGroup Condition="$(MSBuildProjectName.StartsWith('MrGameEng.')) AND !$(MSBuildProjectName.EndsWith('.Tests')) AND !$(MSBuildProjectName.EndsWith('.Generator')) AND !$(MSBuildProjectName.EndsWith('.Sample'))">
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
</Project>
+19 -23
View File
@@ -1,23 +1,19 @@
<Project>
<ItemGroup>
<!-- Engine -->
<PackageVersion Include="MonoGame.Framework.DesktopGL" Version="3.8.4.1" />
<PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" />
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
<PackageVersion Include="NVorbis" Version="0.10.5" />
<PackageVersion Include="Myra" Version="1.6.1" />
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
<PackageVersion Include="StbImageWriteSharp" Version="1.16.7" />
<!-- Source generator -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
<!-- Tests -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
<Project>
<ItemGroup>
<!-- Engine -->
<PackageVersion Include="MonoGame.Framework.DesktopGL" Version="3.8.4.1" />
<PackageVersion Include="Friflo.Engine.ECS" Version="3.6.0" />
<PackageVersion Include="FontStashSharp.MonoGame" Version="1.5.6" />
<PackageVersion Include="NVorbis" Version="0.10.5" />
<PackageVersion Include="Myra" Version="1.6.1" />
<PackageVersion Include="StbImageSharp" Version="2.30.15" />
<PackageVersion Include="StbImageWriteSharp" Version="1.16.7" />
<!-- Source generator -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<!-- Tests -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
+143 -188
View File
@@ -15,43 +15,37 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Graphics", "src\M
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Graphics.Tests", "tests\MrGameEng.Graphics.Tests\MrGameEng.Graphics.Tests.csproj", "{F3C7CE85-EF6B-4CEE-9042-50B8710EF656}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets", "src\MrGameEng.Assets\MrGameEng.Assets.csproj", "{8FDFB833-57DC-4013-8399-5B2F67C9B14E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets.Generator", "src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj", "{FA351009-2001-42A8-8091-54438111E2F1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Assets.Generator.Tests", "tests\MrGameEng.Assets.Generator.Tests\MrGameEng.Assets.Generator.Tests.csproj", "{234790A6-1705-48C3-BF31-3DC79721B1E9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input", "src\MrGameEng.Input\MrGameEng.Input.csproj", "{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio", "src\MrGameEng.Audio\MrGameEng.Audio.csproj", "{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Input.Tests", "tests\MrGameEng.Input.Tests\MrGameEng.Input.Tests.csproj", "{0E0710AB-6132-4E64-9AFC-03B0601F92C6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI", "src\MrGameEng.UI\MrGameEng.UI.csproj", "{17EB97D5-DCF8-47DF-B810-DA45AE314170}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole", "src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj", "{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.DevConsole.Tests", "tests\MrGameEng.DevConsole.Tests\MrGameEng.DevConsole.Tests.csproj", "{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases", "src\MrGameEng.Atlases\MrGameEng.Atlases.csproj", "{B5980FD4-43DF-41B3-97BB-B93D89761FB9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{07C2787E-EAC7-C090-1BA3-A61EC2A24D84}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AtlasTool", "tools\MrGameEng.AtlasTool\MrGameEng.AtlasTool.csproj", "{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Atlases.Tests", "tests\MrGameEng.Atlases.Tests\MrGameEng.Atlases.Tests.csproj", "{1951D50B-122A-45B5-9356-F186A3CBC974}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Content", "src\MrGameEng.Content\MrGameEng.Content.csproj", "{6726902A-59ED-4BDA-B376-947514A19019}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps", "src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj", "{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation", "src\MrGameEng.Simulation\MrGameEng.Simulation.csproj", "{F1C3CB22-B7EC-4258-9504-2549DE290137}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Tilemaps.Tests", "tests\MrGameEng.Tilemaps.Tests\MrGameEng.Tilemaps.Tests.csproj", "{10B318BB-BB00-4A9D-8AF3-D36C570B6286}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Content.Tests", "tests\MrGameEng.Content.Tests\MrGameEng.Content.Tests.csproj", "{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Pathfinding", "src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj", "{84064C54-688F-4A58-9EC6-BFD478816306}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation.Tests", "tests\MrGameEng.Simulation.Tests\MrGameEng.Simulation.Tests.csproj", "{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Collisions", "src\MrGameEng.Collisions\MrGameEng.Collisions.csproj", "{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests\MrGameEng.UI.Tests\MrGameEng.UI.Tests.csproj", "{7F7D9641-2409-40CB-88A9-56BCE8C90A45}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Pathfinding.Tests", "tests\MrGameEng.Pathfinding.Tests\MrGameEng.Pathfinding.Tests.csproj", "{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Collisions.Tests", "tests\MrGameEng.Collisions.Tests\MrGameEng.Collisions.Tests.csproj", "{B8C132F5-C4C8-4931-B0CE-885811F44DB0}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host", "src\MrGameEng.Host\MrGameEng.Host.csproj", "{59818072-0D2B-4007-A50F-1343FA189EC6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Host.Tests", "tests\MrGameEng.Host.Tests\MrGameEng.Host.Tests.csproj", "{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net", "src\MrGameEng.Net\MrGameEng.Net.csproj", "{A4E754C7-C5FD-43A2-B345-34152D3A22D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Net.Tests", "tests\MrGameEng.Net.Tests\MrGameEng.Net.Tests.csproj", "{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -111,18 +105,6 @@ Global
{F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x64.Build.0 = Release|Any CPU
{F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x86.ActiveCfg = Release|Any CPU
{F3C7CE85-EF6B-4CEE-9042-50B8710EF656}.Release|x86.Build.0 = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x64.ActiveCfg = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x64.Build.0 = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x86.ActiveCfg = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Debug|x86.Build.0 = Debug|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|Any CPU.Build.0 = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x64.ActiveCfg = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x64.Build.0 = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x86.ActiveCfg = Release|Any CPU
{8FDFB833-57DC-4013-8399-5B2F67C9B14E}.Release|x86.Build.0 = Release|Any CPU
{FA351009-2001-42A8-8091-54438111E2F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FA351009-2001-42A8-8091-54438111E2F1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA351009-2001-42A8-8091-54438111E2F1}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -147,18 +129,6 @@ Global
{234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x64.Build.0 = Release|Any CPU
{234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x86.ActiveCfg = Release|Any CPU
{234790A6-1705-48C3-BF31-3DC79721B1E9}.Release|x86.Build.0 = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x64.ActiveCfg = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x64.Build.0 = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x86.ActiveCfg = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Debug|x86.Build.0 = Debug|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|Any CPU.Build.0 = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x64.ActiveCfg = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x64.Build.0 = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x86.ActiveCfg = Release|Any CPU
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD}.Release|x86.Build.0 = Release|Any CPU
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -171,18 +141,6 @@ Global
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x64.Build.0 = Release|Any CPU
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x86.ActiveCfg = Release|Any CPU
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE}.Release|x86.Build.0 = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x64.ActiveCfg = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x64.Build.0 = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x86.ActiveCfg = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Debug|x86.Build.0 = Debug|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|Any CPU.Build.0 = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.ActiveCfg = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x64.Build.0 = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.ActiveCfg = Release|Any CPU
{0E0710AB-6132-4E64-9AFC-03B0601F92C6}.Release|x86.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -195,42 +153,6 @@ Global
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x64.Build.0 = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.ActiveCfg = Release|Any CPU
{17EB97D5-DCF8-47DF-B810-DA45AE314170}.Release|x86.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x64.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.ActiveCfg = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Debug|x86.Build.0 = Debug|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|Any CPU.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x64.Build.0 = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.ActiveCfg = Release|Any CPU
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5}.Release|x86.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x64.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.ActiveCfg = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Debug|x86.Build.0 = Debug|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|Any CPU.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x64.Build.0 = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.ActiveCfg = Release|Any CPU
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0}.Release|x86.Build.0 = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.ActiveCfg = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x64.Build.0 = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.ActiveCfg = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Debug|x86.Build.0 = Debug|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|Any CPU.Build.0 = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.ActiveCfg = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x64.Build.0 = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.ActiveCfg = Release|Any CPU
{B5980FD4-43DF-41B3-97BB-B93D89761FB9}.Release|x86.Build.0 = Release|Any CPU
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Debug|x64.ActiveCfg = Debug|Any CPU
@@ -243,90 +165,126 @@ Global
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x64.Build.0 = Release|Any CPU
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.ActiveCfg = Release|Any CPU
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9}.Release|x86.Build.0 = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.ActiveCfg = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x64.Build.0 = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.ActiveCfg = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Debug|x86.Build.0 = Debug|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|Any CPU.Build.0 = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.ActiveCfg = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x64.Build.0 = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.ActiveCfg = Release|Any CPU
{1951D50B-122A-45B5-9356-F186A3CBC974}.Release|x86.Build.0 = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.ActiveCfg = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x64.Build.0 = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.ActiveCfg = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Debug|x86.Build.0 = Debug|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|Any CPU.Build.0 = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.ActiveCfg = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x64.Build.0 = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.ActiveCfg = Release|Any CPU
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9}.Release|x86.Build.0 = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.ActiveCfg = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x64.Build.0 = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.ActiveCfg = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Debug|x86.Build.0 = Debug|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|Any CPU.Build.0 = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.ActiveCfg = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x64.Build.0 = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.ActiveCfg = Release|Any CPU
{10B318BB-BB00-4A9D-8AF3-D36C570B6286}.Release|x86.Build.0 = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|Any CPU.Build.0 = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x64.ActiveCfg = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x64.Build.0 = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x86.ActiveCfg = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Debug|x86.Build.0 = Debug|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|Any CPU.ActiveCfg = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|Any CPU.Build.0 = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x64.ActiveCfg = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x64.Build.0 = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x86.ActiveCfg = Release|Any CPU
{84064C54-688F-4A58-9EC6-BFD478816306}.Release|x86.Build.0 = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x64.ActiveCfg = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x64.Build.0 = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x86.ActiveCfg = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Debug|x86.Build.0 = Debug|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|Any CPU.Build.0 = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x64.ActiveCfg = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x64.Build.0 = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x86.ActiveCfg = Release|Any CPU
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC}.Release|x86.Build.0 = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x64.ActiveCfg = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x64.Build.0 = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x86.ActiveCfg = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Debug|x86.Build.0 = Debug|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|Any CPU.Build.0 = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x64.ActiveCfg = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x64.Build.0 = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x86.ActiveCfg = Release|Any CPU
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3}.Release|x86.Build.0 = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x64.ActiveCfg = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x64.Build.0 = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x86.ActiveCfg = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Debug|x86.Build.0 = Debug|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|Any CPU.Build.0 = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x64.ActiveCfg = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x64.Build.0 = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.ActiveCfg = Release|Any CPU
{B8C132F5-C4C8-4931-B0CE-885811F44DB0}.Release|x86.Build.0 = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x64.ActiveCfg = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x64.Build.0 = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x86.ActiveCfg = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Debug|x86.Build.0 = Debug|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|Any CPU.Build.0 = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x64.ActiveCfg = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x64.Build.0 = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x86.ActiveCfg = Release|Any CPU
{6726902A-59ED-4BDA-B376-947514A19019}.Release|x86.Build.0 = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x64.ActiveCfg = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x64.Build.0 = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x86.ActiveCfg = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Debug|x86.Build.0 = Debug|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|Any CPU.Build.0 = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x64.ActiveCfg = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x64.Build.0 = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x86.ActiveCfg = Release|Any CPU
{F1C3CB22-B7EC-4258-9504-2549DE290137}.Release|x86.Build.0 = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x64.ActiveCfg = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x64.Build.0 = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x86.ActiveCfg = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Debug|x86.Build.0 = Debug|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|Any CPU.Build.0 = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x64.ActiveCfg = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x64.Build.0 = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x86.ActiveCfg = Release|Any CPU
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39}.Release|x86.Build.0 = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x64.ActiveCfg = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x64.Build.0 = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x86.ActiveCfg = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Debug|x86.Build.0 = Debug|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|Any CPU.Build.0 = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x64.ActiveCfg = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x64.Build.0 = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x86.ActiveCfg = Release|Any CPU
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272}.Release|x86.Build.0 = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x64.ActiveCfg = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x64.Build.0 = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x86.ActiveCfg = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Debug|x86.Build.0 = Debug|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|Any CPU.Build.0 = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x64.ActiveCfg = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x64.Build.0 = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.ActiveCfg = Release|Any CPU
{7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.Build.0 = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.ActiveCfg = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.Build.0 = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.ActiveCfg = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.Build.0 = Debug|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.Build.0 = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.ActiveCfg = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.Build.0 = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.ActiveCfg = Release|Any CPU
{4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x64.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.ActiveCfg = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Debug|x86.Build.0 = Debug|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|Any CPU.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x64.Build.0 = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.ActiveCfg = Release|Any CPU
{59818072-0D2B-4007-A50F-1343FA189EC6}.Release|x86.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x64.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.ActiveCfg = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Debug|x86.Build.0 = Debug|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|Any CPU.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x64.Build.0 = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.ActiveCfg = Release|Any CPU
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2}.Release|x86.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x64.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.ActiveCfg = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Debug|x86.Build.0 = Debug|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|Any CPU.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x64.Build.0 = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.ActiveCfg = Release|Any CPU
{A4E754C7-C5FD-43A2-B345-34152D3A22D1}.Release|x86.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|Any CPU.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x64.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.ActiveCfg = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Debug|x86.Build.0 = Debug|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|Any CPU.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x64.Build.0 = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.ActiveCfg = Release|Any CPU
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -336,23 +294,20 @@ Global
{2E9A054C-77A1-484B-BBFF-51FC40CD7D87} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1936FFA7-B642-4B1C-A01A-A3B25BBFCAD1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F3C7CE85-EF6B-4CEE-9042-50B8710EF656} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{8FDFB833-57DC-4013-8399-5B2F67C9B14E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{FA351009-2001-42A8-8091-54438111E2F1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{234790A6-1705-48C3-BF31-3DC79721B1E9} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{9F4D0E2A-1FAB-4DEB-AE7F-768EFB660AFD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D55753A2-4CC0-4BCC-80D5-01E919FF97BE} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0E0710AB-6132-4E64-9AFC-03B0601F92C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{17EB97D5-DCF8-47DF-B810-DA45AE314170} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{5F5E2C77-F2AC-4CC7-9CB7-5E46E4CFACB5} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{0439A95B-5BF6-48AA-9EA9-BE4F6CCF19D0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{B5980FD4-43DF-41B3-97BB-B93D89761FB9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C9782A2A-1D37-4EAF-A628-AB6F399DE9F9} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84}
{1951D50B-122A-45B5-9356-F186A3CBC974} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{43FCB103-4EBA-464D-BF39-9F1FA83E52E9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{10B318BB-BB00-4A9D-8AF3-D36C570B6286} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{84064C54-688F-4A58-9EC6-BFD478816306} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E069CBFD-F4FA-4400-84AE-090F9B81BDFC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{D9FFA22D-0CFF-4A1E-AD56-BA4BD00526B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{B8C132F5-C4C8-4931-B0CE-885811F44DB0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{6726902A-59ED-4BDA-B376-947514A19019} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F1C3CB22-B7EC-4258-9504-2549DE290137} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{59818072-0D2B-4007-A50F-1343FA189EC6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{B3EB5318-5EC0-4F23-8ECA-0EC5A0C4DFD2} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{A4E754C7-C5FD-43A2-B345-34152D3A22D1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{434C24AC-08C6-483E-A2DE-6DBC8A4DE791} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+97 -29
View File
@@ -27,39 +27,76 @@
## Модули
| Библиотека | Ответственность |
|------------------------------|------------------------------------------------------------|
| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время, жизненный цикл |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои |
| `MrGameEng.Input` | Абстракция ввода: клавиатура, мышь, геймпад; action maps |
| `MrGameEng.Audio` | Звуковые эффекты и музыка |
| `MrGameEng.Assets` | Runtime-загрузка ресурсов без Content Pipeline, кэш, `AssetRef<T>` |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов |
| `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` |
| `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер |
| `MrGameEng.Pathfinding` | Поиск пути по гриду: A*, Dijkstra, BFS и flow fields для толп; чистая логика без зависимостей |
| `MrGameEng.Collisions` | Определение столкновений: компонент `Collider`, spatial hash, пары/запросы/raycast |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг |
| `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение |
Библиотеки сгруппированы по архитектурной роли, а не по одной на фичу. Каждая фича —
подпапка библиотеки-хоста со своим неймспейсом `MrGameEng.<Фича>` (неймспейс не
привязан к сборке), поэтому `using MrGameEng.Tilemaps;` и т.п. работают и после слияния.
Планируемые модули (по мере развития): `Physics2D`, `Tilemap`, `UI`, `Particles`.
| Библиотека (сборка) | Фичи (неймспейсы) и ответственность |
|------------------------------|------------------------------------------------------------|
| `MrGameEng.Core` | Платформо-независимое ядро (зависит только от Friflo): `EngineContext`, `EntityStore`, `SystemRoot`, сцены и переходы (тайминг — `Transition`, `SceneManager`; визуал переходов — в `Host`), время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл, `ServiceRegistry`, `Log`. **`HeadlessHost`** — цикл без окна и GPU на фиксированном тике (`Tick`/`RunTicks`/`Run` с realtime-пейсингом): дедикейтед-серверы, батч-симуляция, тесты |
| `MrGameEng.Host` | Оконный MonoGame-хост: `GameHost` (обёртка над `Game` — цикл, окно, `GraphicsDeviceManager`; публикует `GraphicsDevice` сервисом в контексте), визуальные переходы сцен (`OverlayTransition`, фабрики `Transitions.Fade/Wipe`, `TransitionRenderer`). **Input** (`MrGameEng.Input`, `Host/Input/`): клавиатура, мышь, геймпад, action maps |
| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar``Renderer2D.AmbientLight`, `scene.UseDayNight(renderer)`; по-клеточный лайтмап — `LightmapBuilder` (амбиент × окклюзия + точечные `PointLight` с трассировкой теней), накладывается multiply поверх мира через `scene.UseLighting(...)`, сэмплируется `Lighting.SampleAt` |
| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) |
| `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef<T>`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента |
| `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi<TContext>`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast |
| `MrGameEng.Net` | Мультиплеер, совместимый с браузером по построению: WebSocket-сервер (RFC 6455 поверх `TcpListener`, без зависимостей — браузер не умеет UDP, поэтому транспорт движка один — WebSocket), клиент на `ClientWebSocket` (работает в Blazor WASM), оба за poll-интерфейсом `INetConnection`; server-authoritative репликация компонентов: `ReplicationSchema` (unmanaged-компоненты, до 32 типов), `ReplicationServer` (пер-соединенческие дельты против последнего отправленного — ack не нужны поверх надёжного упорядоченного транспорта), `ReplicationClient` (применение снапшотов в локальный `EntityStore`), компонент `NetId` |
| `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение. **Inspector** (`MrGameEng.Inspector`): ECS-дебагер в духе Chrome DevTools — дерево сущностей по архетипам, компоненты/поля с правкой простых полей, выбор кликом по миру с подсветкой, вкладка перфа рендера (`scene.UseInspector(renderer)`, F1) |
| `MrGameEng.Assets.Generator` | Roslyn incremental source generator: классы с типизированными хендлами ресурсов (отдельный анализатор netstandard2.0) |
Планируемые области (по мере развития): `Physics2D`, `Particles` — добавляются как
подпапки подходящей библиотеки или новой библиотекой, если это новая роль/тяжёлая
зависимость.
### MrGameEng.Mods
Контент игры описывается **модами** — папками вида `Mods/<Id>` с метаданными в
`About/About.json` (id, имя, версия, зависимости). `ModLoader.Load` упорядочивает моды
детерминированно: зависимости раньше зависимых, при равенстве — по алфавиту id. Поздний
мод переопределяет ранние во всех системах контента. Сама игра поставляет свой контент
как мод `Core` — любой другой мод может переопределить её данные.
- **Дефы** (`Defs/**/*.json`): файл-конверт `{ "type": "<ключ>", "defs": [ … ] }`;
CLR-тип на ключ регистрирует игра (`DefDatabase.RegisterType<T>`). Поля: `defName`
(уникален в типе; одноимённый деф позднего мода полностью заменяет ранний), `parent`
(поля родителя как основа, свои — поверх; вложенные объекты заменяются целиком),
`abstract` (только родитель, в базу не попадает; не наследуется), `label`.
- **Локализация** (`Languages/<код>/**/*.json`): плоские словари ключ→строка;
`LanguageManager` переключает язык на лету, недостающие ключи берёт из языка по
умолчанию, в крайнем случае возвращает сам ключ.
- **Деревья контента**: `ModContentTree.Build(mods, "Textures")` сливает одноимённые
папки всех модов (поздний мод побеждает по относительному пути) — результат кормится,
например, в `AtlasBuilder.Build(options, sources)` для инкрементальной сборки атласов
при старте игры.
### Правило зависимостей
```
MrGameEng.Graphics ─┐
MrGameEng.Input ─┤
MrGameEng.Audio ─┼──► MrGameEng.Core ──► MonoGame.Framework.DesktopGL
MrGameEng.Assets ─┘ └──► Friflo.Engine.ECS
MrGameEng.Host ─┐
MrGameEng.Audio ─┤
MrGameEng.Net ─┤
MrGameEng.Graphics ─┼──► MrGameEng.Core ──► Friflo.Engine.ECS
MrGameEng.Content ─┤
MrGameEng.Simulation ─┤ (Content — за Texture2DRegion,
MrGameEng.UI ─┴──► MrGameEng.Graphics Simulation/UI — за Transform2D/рендер)
```
Модули зависят **только от `Core`** и никогда друг от друга. `Core` зависит только
от MonoGame и Friflo. Если двум модулям нужен общий тип — он переезжает в `Core`.
Библиотека зависит **только от `Core` и `Graphics`**. `Core` зависит только от Friflo —
ни MonoGame, ни другой платформы: благодаря этому симуляция запускается и без окна
(`HeadlessHost`). MonoGame (`MonoGame.Framework.DesktopGL`) тянут платформенные и
графические библиотеки: `Host`, `Graphics`, `Audio` (и транзитивно их потребители).
Платформенные ресурсы (например `GraphicsDevice`) хосты публикуют сервисами в
`EngineContext.Services`; графический код достаёт девайс через
`context.GetGraphicsDevice()` (расширение в `Graphics`). На `Host` не зависит никто,
кроме самой игры — это точка входа оконной платформы. Если двум библиотекам нужен общий
тип — он переезжает в `Core` (или, если это графический тип, в `Graphics`). `Content`
тянет `Graphics` (атласы выдают `Texture2DRegion`); `Simulation` тянет `Graphics`
(`Collisions` использует `Transform2D`, `RectF`); `UI` — только `Core` (Myra рисует
своим SpriteBatch).
Документированные исключения: `MrGameEng.Atlases` зависит от `Graphics`
(выдаёт `Texture2DRegion`) и от `Assets` (регистрирует загрузчик в `AssetManager`) —
атлас по своей природе склейка этих двух областей; `MrGameEng.Tilemaps` зависит от
`Graphics` (рисует регионы через рендерер и слои).
Фичи, собранные в одну библиотеку, делят её набор пакетов (например, `Content` несёт и
FontStash, и StbImage). Тяжёлые/опциональные зависимости (Myra, NVorbis) держим в
отдельных библиотеках, чтобы остальной движок их не тянул.
`MrGameEng.Assets.Generator` — особый случай: это анализатор (netstandard2.0),
он подключается к проекту игры как `Analyzer`, в рантайме не участвует и не зависит
@@ -204,6 +241,34 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
инвалидируются generation-штампом — повторные запросы не аллоцируют и не чистят
массивы. Один экземпляр на систему; результаты детерминированы.
## ИИ агентов (utility)
`MrGameEng.AI` — примитивы для принятия решений агентами (жители LittleSim).
Зависит только от Core, **не владеет данными мира** и не привязан к ECS: всё
параметризовано контекстом `TContext`, который игра передаёт сама (снимок восприятия,
хендл сущности, blackboard — что угодно). Основан на Infinite-Axis Utility System:
- `ResponseCurve` (value type) — кривая отклика, нормализованный вход `[0,1]` →
полезность `[0,1]`: `Linear`, `Polynomial` (степень), `Logistic` (S-кривая),
`SmoothStep`. Вход и выход клампятся. **Внимание:** `default(ResponseCurve)` имеет
нулевой наклон (всегда 0) — для тождества используйте `ResponseCurve.Identity`.
- `Consideration<TContext>` — одно соображение: читает сырое значение из контекста,
нормирует по диапазону `[min,max]` и прогоняет через кривую.
- `UtilityAction<TContext>` — действие из набора соображений. Очки = произведение
соображений × `Weight`; любой ноль ветирует действие. Компенсирующий множитель
(make-up value) убирает смещение произведения многих факторов вниз.
- `UtilityAi<TContext>` — reasoner: `Select` (детерминированно лучшее действие, при
равенстве — первое) и `SelectWeighted(random)` (рулетка по очкам для разнообразия,
воспроизводимо при seed). Очки пишутся в переиспользуемый буфер — повторные
вычисления не аллоцируют; один экземпляр на вид агента, не потокобезопасен.
- `Blackboard` — типизированная рабочая память агента (`Set`/`TryGet`/`GetOrDefault`)
для холодных путей (восприятие, планирование).
Витрина в LittleSim: `PawnDecisionSystem` выбирает «бродить/отдыхать» по энергии
жителя, `PawnNeedsSystem` тратит/восстанавливает энергию, уставшие темнеют
(`PawnAppearanceSystem`). Команда консоли `ai [energy]` печатает очки и расклад
отдыхающих/блуждающих.
## Коллизии
`MrGameEng.Collisions` — определение столкновений (без разрешения физики — она в бэклоге):
@@ -291,12 +356,15 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто
- `SceneManager` владеет активной сценой; обычное переключение откладывается до начала
следующего кадра (сцена никогда не выгружается посреди собственного кадра).
- `Scenes.Switch(scene, Transition.Fade(0.5f))` — переключение с визуальным переходом:
- `Scenes.Switch(scene, Transitions.Fade(0.5f))` — переключение с визуальным переходом:
фаза закрытия (старая сцена живёт) → своп при полном покрытии → фаза открытия.
Тяжёлый `OnLoad` новой сцены скрыт за полностью закрытым экраном.
- Встроенные переходы: `Transition.Fade(duration, color)` и `Transition.Wipe(duration, color)`
(шторка). Свои — наследованием от `Transition` (рисование через `TransitionRenderer.Fill`
в нормализованных координатах экрана).
- Тайминг-машина (`Transition` — длительности фаз, покрытие) живёт в `Core` и работает
и в headless-контексте; визуал — в `Host`: встроенные `Transitions.Fade(duration, color)`
и `Transitions.Wipe(duration, color)` (шторка). Свои — наследованием от
`OverlayTransition` (рисование через `TransitionRenderer.Fill` в нормализованных
координатах экрана); `GameHost` рисует оверлей поверх сцены, читая
`Scenes.ActiveTransition`/`TransitionCoverage`/`TransitionPhase`.
- Переходы идут по **unscaled**-времени: работают при паузе геймплея (`TimeScale = 0`).
- Повторный `Switch` во время перехода заменяет целевую сцену, не перезапуская переход.
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
namespace MrGameEng.Assets.Generator;
@@ -14,7 +15,9 @@ namespace MrGameEng.Assets.Generator;
[Generator]
public sealed class AssetHandlesGenerator : IIncrementalGenerator
{
private static readonly Dictionary<string, string> TypeByExtension = new(StringComparer.OrdinalIgnoreCase)
private static readonly Dictionary<string, string> TypeByExtension = new(
StringComparer.OrdinalIgnoreCase
)
{
[".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
@@ -31,38 +34,80 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var options = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns);
provider.GlobalOptions.TryGetValue("build_property.MrGameEngAssetsClassName", out var className);
return (
Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!,
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!);
});
var options = context.AnalyzerConfigOptionsProvider.Select(
static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns);
provider.GlobalOptions.TryGetValue(
"build_property.MrGameEngAssetsClassName",
out var className
);
return (
Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!,
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!
);
}
);
var assets = context.AdditionalTextsProvider
.Select(static (text, _) => ToAssetPath(text.Path))
var projectDir = context.AnalyzerConfigOptionsProvider.Select(
static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir);
return dir ?? string.Empty;
}
);
var assets = context
.AdditionalTextsProvider.Combine(projectDir)
.Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right))
.Where(static path => path is not null)
.Collect();
context.RegisterSourceOutput(assets.Combine(options), static (production, input) =>
production.AddSource("GameAssets.g.cs", SourceText.From(Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), Encoding.UTF8)));
context.RegisterSourceOutput(
assets.Combine(options),
static (production, input) =>
production.AddSource(
"GameAssets.g.cs",
SourceText.From(
Emit(input.Left!, input.Right.Namespace, input.Right.ClassName),
Encoding.UTF8
)
)
);
}
/// <summary>
/// Extracts the path relative to the "Assets" directory (forward slashes), or null when
/// the file is outside an Assets directory or has an unknown extension.
/// the file is outside an Assets directory or has an unknown extension. With a known
/// <paramref name="projectDir"/> the marker is the project's root <c>Assets/</c> folder,
/// so nested directories that happen to be called "Assets" do not shift the root;
/// without one, the last <c>/Assets/</c> segment of the path is used.
/// </summary>
internal static string? ToAssetPath(string fullPath)
internal static string? ToAssetPath(string fullPath, string? projectDir = null)
{
var normalized = fullPath.Replace('\\', '/');
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
{
return null;
}
var relative = normalized.Substring(marker + "/Assets/".Length);
string relative;
var root = string.IsNullOrEmpty(projectDir)
? null
: projectDir!.Replace('\\', '/').TrimEnd('/');
if (
root is not null
&& normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase)
)
{
relative = normalized.Substring(root.Length + "/Assets/".Length);
}
else
{
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
{
return null;
}
relative = normalized.Substring(marker + "/Assets/".Length);
}
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
// им собственные Texture2D-хендлы не нужны.
@@ -94,39 +139,47 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
source.AppendLine("// <auto-generated by MrGameEng.Assets.Generator />");
source.AppendLine($"namespace {ns};");
source.AppendLine();
source.AppendLine("/// <summary>Typed handles for every file under the Assets directory.</summary>");
source.AppendLine(
"/// <summary>Typed handles for every file under the Assets directory.</summary>"
);
source.AppendLine($"public static partial class {className}");
source.AppendLine("{");
EmitNode(source, root, indent: 1);
EmitNode(source, root, indent: 1, enclosingName: className);
source.AppendLine("}");
return source.ToString();
}
private static void EmitNode(StringBuilder source, Node node, int indent)
private static void EmitNode(StringBuilder source, Node node, int indent, string enclosingName)
{
var pad = new string(' ', indent * 4);
var usedNames = new HashSet<string>();
// Имя вмещающего класса занято: член с тем же именем — ошибка CS0542.
var usedNames = new HashSet<string> { enclosingName };
foreach (var (fileName, relativePath) in node.Files)
{
var type = TypeByExtension[Path.GetExtension(fileName)];
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
source.AppendLine($"{pad}/// <summary>{relativePath}</summary>");
source.AppendLine($"{pad}/// <summary>{XmlEscape(relativePath)}</summary>");
source.AppendLine(
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");");
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = "
+ $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});"
);
}
foreach (var pair in node.Children)
{
var name = Unique(usedNames, Identifier(pair.Key));
source.AppendLine($"{pad}/// <summary>{pair.Key}/</summary>");
source.AppendLine($"{pad}/// <summary>{XmlEscape(pair.Key)}/</summary>");
source.AppendLine($"{pad}public static class {name}");
source.AppendLine($"{pad}{{");
EmitNode(source, pair.Value, indent + 1);
EmitNode(source, pair.Value, indent + 1, enclosingName: name);
source.AppendLine($"{pad}}}");
}
}
private static string XmlEscape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
internal static string Identifier(string name)
{
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
@@ -14,5 +13,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
</ItemGroup>
</Project>
+17 -1
View File
@@ -19,7 +19,23 @@ public sealed class AudioManager : IDisposable
set => _soundVolume = Math.Clamp(value, 0f, 1f);
}
/// <summary>
/// Master volume 0..1 applied on top of <see cref="SoundVolume"/> for sound effects and
/// mirrored onto <see cref="MusicPlayer.Volume"/>, so a single knob (e.g. a settings
/// slider) controls both effects and music.
/// </summary>
public float MasterVolume
{
get => _masterVolume;
set
{
_masterVolume = Math.Clamp(value, 0f, 1f);
Music.Volume = _masterVolume;
}
}
private float _soundVolume = 1f;
private float _masterVolume = 1f;
/// <summary>Plays a sound effect (fire and forget).</summary>
/// <param name="sound">The loaded sound effect.</param>
@@ -27,7 +43,7 @@ public sealed class AudioManager : IDisposable
/// <param name="pitch">Pitch offset in octaves, -1..1.</param>
/// <param name="pan">Stereo pan, -1 (left) .. 1 (right).</param>
public void Play(SoundEffect sound, float volume = 1f, float pitch = 0f, float pan = 0f) =>
sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume, pitch, pan);
sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume * _masterVolume, pitch, pan);
/// <inheritdoc />
public void Dispose() => Music.Dispose();
+14 -15
View File
@@ -1,15 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="NVorbis" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+34 -6
View File
@@ -40,9 +40,27 @@ public sealed class MusicPlayer : IDisposable
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
public void Play(MusicTrack track, bool loop = true)
{
// Валидация до Stop(): негодный файл не должен обрывать играющий трек.
var reader = new VorbisReader(track.FullPath);
if (reader.Channels is < 1 or > 2)
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported."
);
}
if (reader.SampleRate is < 8000 or > 48000)
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 800048000 Hz."
);
}
Stop();
_loop = loop;
_reader = new VorbisReader(track.FullPath);
_reader = reader;
// ~0.5 seconds of samples per submitted buffer.
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
@@ -51,7 +69,8 @@ public sealed class MusicPlayer : IDisposable
_instance = new DynamicSoundEffectInstance(
_reader.SampleRate,
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo)
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo
)
{
Volume = _volume,
};
@@ -91,13 +110,22 @@ public sealed class MusicPlayer : IDisposable
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
if (read == 0)
{
if (!_loop)
// SamplePosition > 0 отличает конец трека от пустого файла: после перемотки
// на 0 повторный read == 0 не зацикливается, а завершает воспроизведение.
if (_loop && _reader.SamplePosition > 0)
{
return;
_reader.SamplePosition = 0;
continue;
}
_reader.SamplePosition = 0;
continue;
// Конец незацикленного трека (или пустой файл): когда буферы доиграли,
// останавливаем инстанс — иначе IsPlaying остаётся true навсегда.
if (_instance.PendingBufferCount == 0)
{
_instance.Stop();
}
return;
}
for (var i = 0; i < read; i++)
@@ -2,6 +2,7 @@ using FontStashSharp;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Assets;
@@ -39,7 +40,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
public T Load<T>(AssetRef<T> asset) where T : class
public T Load<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.TryGetValue(key, out var cached))
@@ -49,13 +51,18 @@ public sealed class AssetManager : IDisposable
if (!_loaders.TryGetValue(typeof(T), out var loader))
{
throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}.");
throw new InvalidOperationException(
$"No asset loader registered for type {typeof(T)}."
);
}
var fullPath = ResolvePath(asset.Path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Asset '{asset.Path}' not found at '{fullPath}'.", fullPath);
throw new FileNotFoundException(
$"Asset '{asset.Path}' not found at '{fullPath}'.",
fullPath
);
}
var loaded = (T)loader(this, fullPath);
@@ -64,7 +71,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
public void Unload<T>(AssetRef<T> asset) where T : class
public void Unload<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.Remove(key, out var value) && value is IDisposable disposable)
@@ -74,8 +82,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
public void RegisterLoader<T>(Func<AssetManager, string, T> loader) where T : class =>
_loaders[typeof(T)] = loader;
public void RegisterLoader<T>(Func<AssetManager, string, T> loader)
where T : class => _loaders[typeof(T)] = loader;
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
public string ResolvePath(string relativePath) =>
@@ -95,7 +103,11 @@ public sealed class AssetManager : IDisposable
private static Texture2D LoadTexture(EngineContext context, string path)
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
return Texture2D.FromStream(
context.GetGraphicsDevice(),
stream,
DefaultColorProcessors.PremultiplyAlpha
);
}
private static SoundEffect LoadSoundEffect(string path)
@@ -112,7 +124,7 @@ public sealed class AssetManager : IDisposable
}
private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path));
new(context.GetGraphicsDevice(), File.ReadAllBytes(path));
}
/// <summary>Wires the assets module into the engine.</summary>
@@ -7,7 +7,8 @@ namespace MrGameEng.Assets;
/// </summary>
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
public readonly record struct AssetRef<T>(string Path) where T : class
public readonly record struct AssetRef<T>(string Path)
where T : class
{
/// <inheritdoc />
public override string ToString() => $"{typeof(T).Name}:{Path}";
@@ -3,11 +3,14 @@ using StbImageWriteSharp;
namespace MrGameEng.Atlases;
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
/// <summary>Options for one <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
public sealed class AtlasBuildOptions
{
/// <summary>Directory scanned recursively for source images (png/jpg/jpeg/bmp).</summary>
public required string SourceDirectory { get; init; }
/// <summary>
/// Directory scanned recursively for source images (png/jpg/jpeg/bmp). Used only by the
/// directory-scanning overload; the explicit-sources overload ignores it.
/// </summary>
public string SourceDirectory { get; init; } = "";
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
public required string OutputDirectory { get; init; }
@@ -38,10 +41,13 @@ public sealed class AtlasBuildOptions
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
/// <summary>Result of an <see cref="AtlasBuilder.Build"/> run.</summary>
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
public sealed record AtlasBuildResult(
IReadOnlyList<AtlasGroupResult> Groups,
IReadOnlyList<string> DeletedOrphans
);
/// <summary>
/// Build-time utility converting a directory tree of loose images into texture atlases:
@@ -54,18 +60,51 @@ public static class AtlasBuilder
{
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
private readonly record struct SourceFile(
string FullPath,
string Key,
long Size,
long ModifiedTicks
);
/// <summary>Builds (or incrementally refreshes) all atlases from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
if (string.IsNullOrEmpty(options.SourceDirectory))
{
throw new ArgumentException("SourceDirectory is not set.", nameof(options));
}
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
if (!Directory.Exists(sourceRoot))
{
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
throw new DirectoryNotFoundException(
$"Atlas source directory not found: '{sourceRoot}'."
);
}
return Build(
options,
Directory
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath)))
);
}
/// <summary>
/// Builds (or incrementally refreshes) all atlases from an explicit source list —
/// e.g. a texture tree merged across mods, where files with one relative path may live
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
/// </summary>
public static AtlasBuildResult Build(
AtlasBuildOptions options,
IEnumerable<(string FullPath, string RelativePath)> sources
)
{
Directory.CreateDirectory(options.OutputDirectory);
var groups = ScanGroups(sourceRoot, options);
var groups = ScanGroups(sources, options);
var results = new List<AtlasGroupResult>();
foreach (var (name, files) in groups)
{
@@ -77,7 +116,11 @@ public static class AtlasBuilder
}
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName)
internal static (string AtlasName, string Key) ClassifyPath(
string relativePath,
int groupDepth,
string rootAtlasName
)
{
var normalized = relativePath.Replace('\\', '/');
var key = normalized[..normalized.LastIndexOf('.')];
@@ -87,23 +130,35 @@ public static class AtlasBuilder
return (name, key);
}
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
IEnumerable<(string FullPath, string RelativePath)> sources,
AtlasBuildOptions options
)
{
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
foreach (var (fullPath, relative) in sources)
{
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
if (
!SourceExtensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
var relative = Path.GetRelativePath(sourceRoot, fullPath);
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
var (atlasName, key) = ClassifyPath(
relative,
options.GroupDepth,
options.RootAtlasName
);
if (keys.TryGetValue(key, out var existing))
{
throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'.");
throw new InvalidDataException(
$"Duplicate region key '{key}': '{existing}' and '{relative}'."
);
}
keys.Add(key, relative);
@@ -113,14 +168,18 @@ public static class AtlasBuilder
groups.Add(atlasName, list);
}
list.Add((fullPath, key));
var info = new FileInfo(fullPath);
list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
string name, List<(string FullPath, string Key)> files, AtlasBuildOptions options)
string name,
List<SourceFile> files,
AtlasBuildOptions options
)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
@@ -130,11 +189,18 @@ public static class AtlasBuilder
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
var images = new ImageResult[files.Count];
Parallel.For(0, files.Count, i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
});
Parallel.For(
0,
files.Count,
i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(
stream,
StbImageSharp.ColorComponents.RedGreenBlueAlpha
);
}
);
var items = new PackItem[files.Count];
for (var i = 0; i < files.Count; i++)
@@ -151,14 +217,18 @@ public static class AtlasBuilder
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
WriteMetadata(name, packed, options, metadataPath);
WriteMetadata(name, packed, files, options, metadataPath);
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
}
private static bool IsUpToDate(
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
string metadataPath,
List<SourceFile> files,
AtlasBuildOptions options,
out int pages
)
{
pages = 0;
if (!File.Exists(metadataPath))
@@ -176,7 +246,11 @@ public static class AtlasBuilder
return false;
}
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
if (
metadata.Version != AtlasMetadata.CurrentVersion
|| metadata.PageSize != options.MaxPageSize
|| metadata.Padding != options.Padding
)
{
return false;
}
@@ -187,16 +261,24 @@ public static class AtlasBuilder
return false;
}
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
if (metadata.Sources.Count != files.Count)
{
return false;
}
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
foreach (var file in files)
{
return false;
if (
!sourcesByKey.TryGetValue(file.Key, out var source)
|| source.Size != file.Size
|| source.Modified != file.ModifiedTicks
)
{
return false;
}
}
pages = metadata.Pages.Count;
@@ -204,52 +286,88 @@ public static class AtlasBuilder
}
private static void WritePages(
string name, PackResult packed, Dictionary<string, ImageResult> pixelsByKey, string outputDirectory)
string name,
PackResult packed,
Dictionary<string, ImageResult> pixelsByKey,
string outputDirectory
)
{
Parallel.For(0, packed.PageSizes.Count, page =>
{
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
Parallel.For(
0,
packed.PageSizes.Count,
page =>
{
if (placement.Page != page)
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
{
continue;
if (placement.Page != page)
{
continue;
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data,
row * source.Width * 4,
buffer,
((placement.Y + row) * width + placement.X) * 4,
source.Width * 4
);
}
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data, row * source.Width * 4,
buffer, ((placement.Y + row) * width + placement.X) * 4,
source.Width * 4);
}
using var stream = File.Create(
Path.Combine(outputDirectory, PageFileName(name, page))
);
new ImageWriter().WritePng(
buffer,
width,
height,
StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha,
stream
);
}
using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page)));
new ImageWriter().WritePng(
buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
});
);
}
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
private static void WriteMetadata(
string name,
PackResult packed,
List<SourceFile> files,
AtlasBuildOptions options,
string metadataPath
)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
Padding = options.Padding,
Pages = packed.PageSizes
.Select((size, index) => new AtlasPage
Sources = files
.OrderBy(f => f.Key, StringComparer.Ordinal)
.Select(f => new AtlasSource
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
Key = f.Key,
Size = f.Size,
Modified = f.ModifiedTicks,
})
.ToList(),
Regions = packed.Placements
.OrderBy(p => p.Key, StringComparer.Ordinal)
Pages = packed
.PageSizes.Select(
(size, index) =>
new AtlasPage
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
}
)
.ToList(),
Regions = packed
.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => new AtlasRegion
{
Key = p.Key,
@@ -265,7 +383,8 @@ public static class AtlasBuilder
File.WriteAllText(metadataPath, metadata.ToJson());
}
private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png";
private static string PageFileName(string atlasName, int page) =>
$"{atlasName}.atlas.{page}.png";
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
{
@@ -281,7 +400,10 @@ public static class AtlasBuilder
}
}
private static List<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
private static List<string> DeleteOrphans(
string outputDirectory,
IEnumerable<string> liveAtlasNames
)
{
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
var deleted = new List<string>();
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>Format version, bumped on breaking metadata changes.</summary>
public int Version { get; init; } = 1;
/// <summary>Current format version. Bumped on breaking metadata changes.</summary>
public const int CurrentVersion = 2;
/// <summary>Format version of this file; readers reject other versions.</summary>
public int Version { get; init; } = CurrentVersion;
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
public string Name { get; init; } = "";
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
/// <summary>Packed regions, sorted by key.</summary>
public List<AtlasRegion> Regions { get; init; } = [];
/// <summary>Source files the atlas was built from, sorted by key (staleness check input).</summary>
public List<AtlasSource> Sources { get; init; } = [];
/// <summary>Serializes this metadata to indented JSON.</summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
@@ -55,6 +61,23 @@ public sealed class AtlasPage
public int Height { get; init; }
}
/// <summary>
/// Snapshot of one source file at build time. The incremental rebuild compares key, size and
/// modification time instead of relying on "source newer than metadata", so timestamp-preserving
/// renames and copies still invalidate the atlas.
/// </summary>
public sealed class AtlasSource
{
/// <summary>Region key of the source file.</summary>
public string Key { get; init; } = "";
/// <summary>Source file size in bytes.</summary>
public long Size { get; init; }
/// <summary>Source file <c>LastWriteTimeUtc</c> in ticks.</summary>
public long Modified { get; init; }
}
/// <summary>One packed source texture inside an atlas.</summary>
public sealed class AtlasRegion
{
@@ -1,5 +1,6 @@
using MrGameEng.Assets;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases;
@@ -14,6 +15,6 @@ public static class AtlasesEngineExtensions
public static void UseTextureAtlases(this EngineContext context)
{
var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GetGraphicsDevice(), path));
}
}
@@ -13,12 +13,25 @@ public readonly record struct PackItem(string Key, int Width, int Height);
/// <param name="Y">Y position in page pixels.</param>
/// <param name="Width">Item width in pixels.</param>
/// <param name="Height">Item height in pixels.</param>
public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
public readonly record struct PackPlacement(
string Key,
int Page,
int X,
int Y,
int Width,
int Height
);
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
/// <param name="PageSizes">
/// Width/height of each page: the next power of two covering its content, clamped to the
/// page-size limit. Dedicated pages of oversized items keep their exact (padded) size.
/// </param>
public sealed record PackResult(
IReadOnlyList<PackPlacement> Placements,
IReadOnlyList<(int Width, int Height)> PageSizes
);
/// <summary>
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
@@ -38,17 +51,19 @@ public static class ShelfPacker
ArgumentOutOfRangeException.ThrowIfNegative(padding);
var sorted = items.ToList();
sorted.Sort(static (a, b) =>
{
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
sorted.Sort(
static (a, b) =>
{
return byHeight;
}
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
{
return byHeight;
}
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
});
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
}
);
var placements = new List<PackPlacement>(items.Count);
var pageSizes = new List<(int Width, int Height)>();
@@ -65,7 +80,12 @@ public static class ShelfPacker
{
if (open)
{
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
pageSizes.Add(
(
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)
)
);
open = false;
}
}
@@ -81,15 +101,31 @@ public static class ShelfPacker
usedHeight = 0;
}
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
foreach (var item in sorted)
{
// Слишком большой для общей страницы — отдельная страница точно под него.
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
CloseOpenPage();
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
continue;
placements.Add(
new PackPlacement(
item.Key,
pageSizes.Count,
padding,
padding,
item.Width,
item.Height
)
);
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
}
}
foreach (var item in sorted)
{
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
continue; // уже размещён на отдельной странице
}
if (!open)
@@ -108,7 +144,9 @@ public static class ShelfPacker
}
}
placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height));
placements.Add(
new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height)
);
x += item.Width + padding;
shelfHeight = Math.Max(shelfHeight, item.Height);
usedWidth = Math.Max(usedWidth, x - padding);
@@ -119,6 +157,11 @@ public static class ShelfPacker
return new PackResult(placements, pageSizes);
}
// POT удобен GPU, но страница не должна превышать заявленный лимит,
// когда maxPageSize сам не степень двойки.
private static int PageDimension(int used, int maxPageSize) =>
Math.Min(NextPowerOfTwo(used), maxPageSize);
internal static int NextPowerOfTwo(int value)
{
var result = 1;
@@ -28,14 +28,19 @@ public sealed class TextureAtlas : IDisposable
{
Name = metadata.Name;
Pages = pages;
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
_regions = new Dictionary<string, Texture2DRegion>(
metadata.Regions.Count,
StringComparer.Ordinal
);
foreach (var region in metadata.Regions)
{
_regions.Add(
region.Key,
new Texture2DRegion(
pages[region.Page],
new Rectangle(region.X, region.Y, region.Width, region.Height)));
new Rectangle(region.X, region.Y, region.Width, region.Height)
)
);
}
}
@@ -56,12 +61,36 @@ public sealed class TextureAtlas : IDisposable
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
{
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
if (metadata.Version != AtlasMetadata.CurrentVersion)
{
throw new InvalidDataException(
$"Atlas '{metadataPath}' has format version {metadata.Version}, expected "
+ $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool."
);
}
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
var pages = new Texture2D[metadata.Pages.Count];
for (var i = 0; i < pages.Length; i++)
try
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
for (var i = 0; i < pages.Length; i++)
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(
graphicsDevice,
stream,
DefaultColorProcessors.PremultiplyAlpha
);
}
}
catch
{
foreach (var page in pages)
{
page?.Dispose(); // частично загруженные страницы не должны утекать
}
throw;
}
return new TextureAtlas(metadata, pages);
+16
View File
@@ -0,0 +1,16 @@
namespace MrGameEng.Genetics;
/// <summary>
/// A diploid gene slot: the two allele values an individual carries for one gene. Stored as floats
/// for both gene kinds — a <see cref="GeneKind.Discrete"/> gene simply holds integral variant
/// indices. How the pair becomes a single phenotype value is decided by the gene's
/// <see cref="GeneKind"/> (see <see cref="Genome.Express"/>).
/// </summary>
public readonly record struct Allele(float A, float B)
{
/// <summary>The average of the two alleles — the phenotype of a numeric gene.</summary>
public float Mean => (A + B) * 0.5f;
/// <summary>The lower (dominant) of the two alleles — the phenotype of a discrete gene.</summary>
public float Dominant => MathF.Min(A, B);
}
+97
View File
@@ -0,0 +1,97 @@
using System.Text.Json.Serialization;
using MrGameEng.Formulas;
using MrGameEng.Mods;
namespace MrGameEng.Genetics;
/// <summary>How a gene's two alleles are stored and expressed into a phenotype value.</summary>
public enum GeneKind
{
/// <summary>A continuous value; the phenotype is the average of the two alleles (hybrid blending).</summary>
Numeric,
/// <summary>
/// A discrete allele index in <c>[0, Variants)</c>; the lower index is dominant, so the
/// phenotype is <c>min(a, b)</c> — a higher (recessive) variant shows only when homozygous.
/// A two-variant discrete gene is effectively a flag.
/// </summary>
Discrete,
}
/// <summary>
/// An organism-agnostic gene definition — the unit the whole gene system is built from. A
/// <see cref="GeneDef"/> describes how to generate an individual's two alleles, how they mutate
/// when bred, and how the gene <see cref="Effects"/> contribute to named phenotype traits via
/// <see cref="Formula"/> expressions. Nothing here is plant-, animal- or human-specific, so the
/// same machinery drives any organism and arbitrary hybrids (a genome can carry any mix of genes).
/// </summary>
public sealed class GeneDef : Def
{
/// <summary>Whether the gene is continuous or a discrete dominant/recessive allele.</summary>
public GeneKind Kind { get; init; } = GeneKind.Numeric;
/// <summary>Numeric: the central value an allele is generated around.</summary>
public float Default { get; init; }
/// <summary>Numeric: lower clamp for generated and mutated allele values.</summary>
public float Min { get; init; } = float.NegativeInfinity;
/// <summary>Numeric: upper clamp for generated and mutated allele values.</summary>
public float Max { get; init; } = float.PositiveInfinity;
/// <summary>Numeric: relative spread of generated alleles around <see cref="Default"/> (allele = Default ± Spread·|Default|).</summary>
public float Spread { get; init; }
/// <summary>Numeric: relative magnitude of a mutation step (value ± Magnitude·|value|).</summary>
public float MutationMagnitude { get; init; } = 0.1f;
/// <summary>Discrete: number of allele variants, valued <c>0..Variants-1</c>.</summary>
public int Variants { get; init; } = 2;
/// <summary>
/// Discrete: relative weights for generating each variant (length <see cref="Variants"/>).
/// Empty means a uniform distribution.
/// </summary>
public float[] VariantWeights { get; init; } = [];
/// <summary>Probability, per allele, that a mutation occurs when this gene is passed to a child.</summary>
public float MutationChance { get; init; }
/// <summary>
/// The gene's contributions to phenotype traits: trait name → formula. Each formula may use the
/// variable <c>value</c> (this gene's expressed phenotype), any other gene's id (its expressed
/// value) and any environment variable the caller supplies. Contributions to the same trait
/// across genes are summed.
/// </summary>
public Dictionary<string, string> Effects { get; init; } = new();
/// <summary>Free-form category tags for grouping genes (used by formula grouping and content tooling).</summary>
public string[] Tags { get; init; } = [];
private IReadOnlyDictionary<string, Formula>? _compiled;
/// <summary>The <see cref="Effects"/> compiled once into evaluable formulas (lazy, cached).</summary>
[JsonIgnore]
public IReadOnlyDictionary<string, Formula> CompiledEffects => _compiled ??= CompileEffects();
private Dictionary<string, Formula> CompileEffects()
{
var compiled = new Dictionary<string, Formula>(StringComparer.Ordinal);
foreach (var (trait, expression) in Effects)
{
try
{
compiled[trait] = Formula.Compile(expression);
}
catch (FormulaException error)
{
throw new InvalidDataException(
$"Gene '{DefName}' effect on trait '{trait}' has an invalid formula "
+ $"\"{expression}\": {error.Message}"
);
}
}
return compiled;
}
}
@@ -0,0 +1,56 @@
namespace MrGameEng.Genetics;
/// <summary>
/// Shared, deterministic allele sampling used by both <see cref="Genome.Generate"/> (gene defaults)
/// and <see cref="GenomeTemplate"/> (per-individual base overrides). Centralizes the numeric
/// spread+clamp and the weighted discrete pick so the two paths stay consistent.
/// </summary>
internal static class GeneSampling
{
/// <summary>A numeric allele drawn as <c>baseValue ± spread·|baseValue|</c>, clamped to the gene's range.</summary>
public static float Numeric(GeneDef gene, float baseValue, float spread, Random random)
{
var value = baseValue + (random.NextSingle() * 2f - 1f) * spread * MathF.Abs(baseValue);
return Math.Clamp(value, gene.Min, gene.Max);
}
/// <summary>
/// A discrete variant index in <c>[0, Variants)</c>, picked from <paramref name="weights"/> when
/// they match the variant count, otherwise the gene's own weights, otherwise uniformly.
/// </summary>
public static float Variant(GeneDef gene, float[]? weights, Random random)
{
var variants = Math.Max(1, gene.Variants);
var w =
weights is { Length: > 0 } && weights.Length == variants
? weights
: gene.VariantWeights;
if (w.Length != variants)
{
return random.Next(variants);
}
var total = 0f;
foreach (var value in w)
{
total += MathF.Max(0f, value);
}
if (total <= 0f)
{
return random.Next(variants);
}
var roll = random.NextSingle() * total;
for (var i = 0; i < variants; i++)
{
roll -= MathF.Max(0f, w[i]);
if (roll < 0f)
{
return i;
}
}
return variants - 1;
}
}
+156
View File
@@ -0,0 +1,156 @@
namespace MrGameEng.Genetics;
/// <summary>
/// An individual's managed genome: a variable-composition map from gene id to the
/// <see cref="Allele"/> pair it carries. Because composition is open, two organisms need not share
/// the same gene set and a genome can gain "foreign" genes — the basis for arbitrary hybrids. The
/// genome is generated from a set of <see cref="GeneDef"/>s, bred meiotically with mutation, and
/// expressed into phenotype values; all randomness flows through a caller-owned seeded
/// <see cref="Random"/> so the simulation stays deterministic.
/// </summary>
public sealed class Genome
{
private readonly Dictionary<string, Allele> _alleles;
/// <summary>Creates an empty genome.</summary>
public Genome() => _alleles = new Dictionary<string, Allele>(StringComparer.Ordinal);
/// <summary>Creates a genome from an existing allele map (copied).</summary>
public Genome(IReadOnlyDictionary<string, Allele> alleles) =>
_alleles = new Dictionary<string, Allele>(alleles, StringComparer.Ordinal);
/// <summary>The carried genes and their allele pairs.</summary>
public IReadOnlyDictionary<string, Allele> Alleles => _alleles;
/// <summary>Whether the genome carries the gene <paramref name="geneId"/>.</summary>
public bool Has(string geneId) => _alleles.ContainsKey(geneId);
/// <summary>Gets or sets the allele pair for <paramref name="geneId"/>.</summary>
public Allele this[string geneId]
{
get => _alleles[geneId];
set => _alleles[geneId] = value;
}
/// <summary>Removes a gene from the genome; returns whether it was present.</summary>
public bool Remove(string geneId) => _alleles.Remove(geneId);
/// <summary>
/// Expresses the gene's phenotype value: the mean of the alleles for a numeric gene, the
/// dominant (lower) allele for a discrete one. Throws if the genome does not carry the gene.
/// </summary>
public float Express(GeneDef gene)
{
if (!_alleles.TryGetValue(gene.DefName, out var allele))
{
throw new KeyNotFoundException($"Genome does not carry gene '{gene.DefName}'.");
}
return gene.Kind == GeneKind.Numeric ? allele.Mean : allele.Dominant;
}
/// <summary>Builds the allele map for serialization (a copy).</summary>
public Dictionary<string, Allele> ToDictionary() => new(_alleles, StringComparer.Ordinal);
/// <summary>
/// Generates a fresh genome carrying every gene in <paramref name="genes"/>, each allele drawn
/// independently around the gene's default with its spread (numeric) or from its variant
/// distribution (discrete).
/// </summary>
public static Genome Generate(IEnumerable<GeneDef> genes, Random random)
{
var genome = new Genome();
foreach (var gene in genes)
{
genome._alleles[gene.DefName] = new Allele(
GenerateAllele(gene, random),
GenerateAllele(gene, random)
);
}
return genome;
}
/// <summary>
/// Breeds a child genome from two parents (meiosis): the child carries every gene either parent
/// has. For a gene both carry, one allele is drawn from each parent; for a gene only one parent
/// carries, it is inherited (from that parent, on both sides) with 50% probability. Every
/// inherited allele may then mutate per its <see cref="GeneDef"/>. <paramref name="registry"/>
/// supplies the def for each gene id; genes absent from it are skipped.
/// <paramref name="mutationChance"/>, when given, overrides every gene's
/// <see cref="GeneDef.MutationChance"/> — letting the caller drive mutation from an evolvable
/// trait rather than a fixed per-gene constant.
/// </summary>
public static Genome Breed(
Genome a,
Genome b,
IReadOnlyDictionary<string, GeneDef> registry,
Random random,
float? mutationChance = null
)
{
var child = new Genome();
foreach (var geneId in UnionKeys(a, b))
{
if (!registry.TryGetValue(geneId, out var gene))
{
continue;
}
var chance = mutationChance ?? gene.MutationChance;
var inA = a.Has(geneId);
var inB = b.Has(geneId);
if (inA && inB)
{
child._alleles[geneId] = new Allele(
Meiosis(gene, a[geneId], chance, random),
Meiosis(gene, b[geneId], chance, random)
);
}
else if (random.NextSingle() < 0.5f)
{
var parent = inA ? a : b;
child._alleles[geneId] = new Allele(
Meiosis(gene, parent[geneId], chance, random),
Meiosis(gene, parent[geneId], chance, random)
);
}
}
return child;
}
// One inherited allele: pick one of the parent slot's two alleles, then maybe mutate it.
private static float Meiosis(GeneDef gene, Allele parent, float mutationChance, Random random)
{
var inherited = random.NextSingle() < 0.5f ? parent.A : parent.B;
if (random.NextSingle() >= mutationChance)
{
return inherited;
}
if (gene.Kind == GeneKind.Discrete)
{
return GeneSampling.Variant(gene, null, random);
}
var shifted =
inherited
+ (random.NextSingle() * 2f - 1f) * gene.MutationMagnitude * MathF.Abs(inherited);
return Math.Clamp(shifted, gene.Min, gene.Max);
}
private static float GenerateAllele(GeneDef gene, Random random) =>
gene.Kind == GeneKind.Discrete
? GeneSampling.Variant(gene, null, random)
: GeneSampling.Numeric(gene, gene.Default, gene.Spread, random);
// Deterministic union of both parents' gene ids (ordered) so breeding is reproducible.
private static IEnumerable<string> UnionKeys(Genome a, Genome b)
{
var keys = new SortedSet<string>(StringComparer.Ordinal);
keys.UnionWith(a._alleles.Keys);
keys.UnionWith(b._alleles.Keys);
return keys;
}
}
@@ -0,0 +1,61 @@
namespace MrGameEng.Genetics;
/// <summary>
/// A species' (or any organism kind's) gene allotment: which <see cref="GeneDef"/>s an individual
/// carries and the per-organism base values its alleles are generated around. The same
/// <see cref="GeneDef"/> (e.g. "optimal light") is shared by every species, while the template
/// supplies the species-specific centre and spread — so an oak and grass differ in values, not in
/// machinery. <see cref="Generate"/> draws a fresh individual; <see cref="Registry"/> feeds breeding
/// and trait computation.
/// </summary>
public sealed class GenomeTemplate
{
/// <summary>
/// One gene in the allotment. <paramref name="Base"/>/<paramref name="Spread"/> centre a numeric
/// gene's alleles; <paramref name="VariantWeights"/> (optional) override a discrete gene's
/// variant distribution for this organism.
/// </summary>
public readonly record struct Entry(
GeneDef Gene,
float Base,
float Spread,
float[]? VariantWeights = null
);
private readonly List<Entry> _entries;
private readonly Dictionary<string, GeneDef> _registry;
/// <summary>Builds a template from its gene entries.</summary>
public GenomeTemplate(IEnumerable<Entry> entries)
{
_entries = entries.ToList();
_registry = new Dictionary<string, GeneDef>(StringComparer.Ordinal);
foreach (var entry in _entries)
{
_registry[entry.Gene.DefName] = entry.Gene;
}
}
/// <summary>The gene entries that make up the allotment.</summary>
public IReadOnlyList<Entry> Entries => _entries;
/// <summary>Gene id → def for every carried gene; pass to <see cref="Genome.Breed"/> and <see cref="Phenotype.Compute"/>.</summary>
public IReadOnlyDictionary<string, GeneDef> Registry => _registry;
/// <summary>Generates a fresh individual: two alleles per gene drawn around each entry's base/variant.</summary>
public Genome Generate(Random random)
{
var genome = new Genome();
foreach (var entry in _entries)
{
genome[entry.Gene.DefName] = new Allele(Draw(entry, random), Draw(entry, random));
}
return genome;
}
private static float Draw(Entry entry, Random random) =>
entry.Gene.Kind == GeneKind.Discrete
? GeneSampling.Variant(entry.Gene, entry.VariantWeights, random)
: GeneSampling.Numeric(entry.Gene, entry.Base, entry.Spread, random);
}
@@ -0,0 +1,87 @@
using MrGameEng.Formulas;
namespace MrGameEng.Genetics;
/// <summary>
/// Computes the trait layer — the phenotype the simulation actually reads — from a
/// <see cref="Genome"/>. Each gene's <see cref="GeneDef.Effects"/> formulas are evaluated and their
/// results summed per trait name, so systems never touch genes directly: one fruiting system reads
/// a <c>fruitYield</c> trait whether it comes from a tree or a human carrying a "fruit" gene.
/// Formulas see the variable <c>value</c> (the contributing gene's expressed phenotype), any other
/// carried gene's id, and whatever environment variables the caller supplies.
/// </summary>
public static class Phenotype
{
/// <summary>
/// Evaluates every carried gene's effects against the genome and an optional
/// <paramref name="environment"/>, summing contributions into a trait map. Genes missing from
/// <paramref name="registry"/> are skipped.
/// </summary>
public static Dictionary<string, float> Compute(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment = null
)
{
var traits = new Dictionary<string, float>(StringComparer.Ordinal);
var context = new GenomeContext(genome, registry, environment);
foreach (var geneId in genome.Alleles.Keys)
{
if (!registry.TryGetValue(geneId, out var gene) || gene.CompiledEffects.Count == 0)
{
continue;
}
context.Self = genome.Express(gene);
foreach (var (trait, formula) in gene.CompiledEffects)
{
traits[trait] = traits.GetValueOrDefault(trait) + formula.Evaluate(context);
}
}
return traits;
}
// Resolves formula variables for a gene effect: 'value' is the current gene's phenotype, any
// carried gene's id resolves to its phenotype, anything else falls through to the environment.
private sealed class GenomeContext(
Genome genome,
IReadOnlyDictionary<string, GeneDef> registry,
IFormulaContext? environment
) : IFormulaContext
{
public float Self;
public float Resolve(string name)
{
if (name == "value")
{
return Self;
}
if (genome.Has(name) && registry.TryGetValue(name, out var gene))
{
return genome.Express(gene);
}
if (environment is not null)
{
return environment.Resolve(name);
}
throw new FormulaException($"Unknown variable '{name}' while computing traits.");
}
// Группировка генов в формулах: значения всех генов, чьи id подходят под шаблон (gsum/gavg/…).
public IEnumerable<float> ResolveMatching(Func<string, bool> matches)
{
foreach (var geneId in genome.Alleles.Keys)
{
if (matches(geneId) && registry.TryGetValue(geneId, out var gene))
{
yield return genome.Express(gene);
}
}
}
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace MrGameEng.Mods;
/// <summary>
/// Base class of all data definitions loaded by <see cref="DefDatabase"/> from mod JSON.
/// Games subclass this per content kind (terrain, things, pawns, …) with plain
/// serializable properties.
/// </summary>
public abstract class Def
{
/// <summary>
/// Unique name within the def type. A later mod redefining the same name fully
/// replaces the earlier def.
/// </summary>
public string DefName { get; init; } = "";
/// <summary>
/// Name of the def (same type) whose fields this def starts from; own fields override
/// the inherited ones. Abstractness is not inherited.
/// </summary>
public string? Parent { get; init; }
/// <summary>Abstract defs only serve as parents and are not emitted into the database.</summary>
public bool Abstract { get; init; }
/// <summary>Display label — plain text or a localization key, as the game decides.</summary>
public string Label { get; init; } = "";
/// <inheritdoc />
public override string ToString() => $"{GetType().Name} {DefName}";
}
+376
View File
@@ -0,0 +1,376 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace MrGameEng.Mods;
/// <summary>
/// Database of data definitions loaded from mod JSON. Each file under a mod's
/// <c>Defs/</c> folder is an envelope <c>{ "type": "&lt;key&gt;", "defs": [ … ] }</c>; the
/// game registers the CLR type for every key before <see cref="Load"/>. Defs from later
/// mods replace same-named defs of earlier mods; <c>parent</c> chains are merged
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
/// <c>abstract</c> serve only as parents.
/// </summary>
public sealed class DefDatabase
{
private sealed class TypeEntry
{
public required string Key;
public required Type ClrType;
public readonly Dictionary<string, JsonObject> Raw = new(StringComparer.Ordinal);
public readonly SortedDictionary<string, Def> Resolved = new(StringComparer.Ordinal);
}
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = [];
/// <summary>Reserved def-file <c>"type"</c> that carries content patches rather than defs.</summary>
public const string PatchTypeKey = "Patch";
private sealed record PatchRule(string DefType, Regex Match, JsonObject Set);
private sealed record ValidationRule(string Field, Regex Pattern, string Description);
private readonly List<PatchRule> _patches = [];
private readonly Dictionary<string, List<ValidationRule>> _validators = new(
StringComparer.OrdinalIgnoreCase
);
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
public void RegisterType<T>(string typeKey)
where T : Def
{
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
if (!_byKey.TryAdd(typeKey, entry))
{
throw new InvalidOperationException($"Def type '{typeKey}' is already registered.");
}
_byType.Add(typeof(T), entry);
}
/// <summary>
/// Registers a load-time validation: the string <paramref name="field"/> of every resolved def of
/// type <paramref name="typeKey"/> must match <paramref name="pattern"/> (a regex), or
/// <see cref="Load"/> throws. Non-string or absent fields are skipped. Use it to enforce naming
/// conventions (e.g. gene ids start with <c>Gene</c>) or key/format rules across a mod's content.
/// </summary>
public void RegisterValidator(
string typeKey,
string field,
string pattern,
string? description = null
)
{
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new ArgumentException($"Invalid validator regex '{pattern}': {error.Message}");
}
if (!_validators.TryGetValue(typeKey, out var list))
{
list = [];
_validators[typeKey] = list;
}
list.Add(new ValidationRule(field, regex, description ?? $"pattern /{pattern}/"));
}
/// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types.
/// </summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var defsDir = mod.ContentPath("Defs");
if (!Directory.Exists(defsDir))
{
continue;
}
var files = Directory
.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file);
}
}
ApplyPatches();
foreach (var entry in _byKey.Values)
{
Resolve(entry);
}
}
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
public T Get<T>(string defName)
where T : Def =>
TryGet<T>(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
public bool TryGet<T>(string defName, out T def)
where T : Def
{
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
{
def = (T)found;
return true;
}
def = null!;
return false;
}
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
public IReadOnlyList<T> All<T>()
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
/// <summary>Registered def-type keys, sorted.</summary>
public IReadOnlyList<string> TypeKeys =>
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
public IReadOnlyList<string> NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry<T>()
where T : Def =>
_byType.TryGetValue(typeof(T), out var entry)
? entry
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
private void LoadFile(Mod mod, string file)
{
JsonNode root;
try
{
root =
JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
var typeKey =
root["type"]?.GetValue<string>()
?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
);
if (string.Equals(typeKey, PatchTypeKey, StringComparison.OrdinalIgnoreCase))
{
LoadPatches(mod, file, root);
return;
}
if (!_byKey.TryGetValue(typeKey, out var entry))
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; "
+ $"registered: {string.Join(", ", TypeKeys)}."
);
}
if (root["defs"] is not JsonArray defs)
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."
);
}
foreach (var node in defs)
{
if (node is not JsonObject def)
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."
);
}
var defName = def["defName"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(defName))
{
throw new InvalidDataException(
$"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."
);
}
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
}
}
// Парсит файл-патч: операции { defType, match (регэксп по defName), set: {поля} }.
private void LoadPatches(Mod mod, string file, JsonNode root)
{
if (root["patches"] is not JsonArray patches)
{
throw new InvalidDataException(
$"Patch file '{file}' (mod '{mod.Id}') has no \"patches\" array."
);
}
foreach (var node in patches)
{
if (node is not JsonObject patch)
{
throw new InvalidDataException(
$"Patch file '{file}' (mod '{mod.Id}') contains a non-object patch."
);
}
var defType =
patch["defType"]?.GetValue<string>()
?? throw new InvalidDataException($"A patch in '{file}' has no \"defType\".");
var match =
patch["match"]?.GetValue<string>()
?? throw new InvalidDataException($"A patch in '{file}' has no \"match\".");
if (patch["set"] is not JsonObject set)
{
throw new InvalidDataException($"A patch in '{file}' has no \"set\" object.");
}
Regex regex;
try
{
regex = new Regex(match, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new InvalidDataException(
$"Patch in '{file}' has invalid regex '{match}': {error.Message}"
);
}
_patches.Add(new PatchRule(defType, regex, (JsonObject)set.DeepClone()));
}
}
// Применяет патчи к сырым дефам (до резолва), в порядке загрузки: каждому дефу нужного типа,
// чьё имя подходит под регэксп, проставляются поля set. Поля наследуются детьми как обычно.
private void ApplyPatches()
{
foreach (var patch in _patches)
{
if (!_byKey.TryGetValue(patch.DefType, out var entry))
{
throw new InvalidDataException(
$"A patch targets unknown def type '{patch.DefType}'."
);
}
foreach (var raw in entry.Raw)
{
if (!patch.Match.IsMatch(raw.Key))
{
continue;
}
foreach (var (key, value) in patch.Set)
{
raw.Value[key] = value?.DeepClone();
}
}
}
}
private void Resolve(TypeEntry entry)
{
_validators.TryGetValue(entry.Key, out var rules);
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{
var merged = MergeChain(entry, defName, []);
if (merged["abstract"]?.GetValue<bool>() == true)
{
continue;
}
if (rules is not null)
{
Validate(entry.Key, defName, merged, rules);
}
var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException(
$"Def '{defName}' ({entry.Key}) deserialized to null."
);
entry.Resolved[defName] = def;
}
}
private static void Validate(
string typeKey,
string defName,
JsonObject merged,
List<ValidationRule> rules
)
{
foreach (var rule in rules)
{
if (
merged[rule.Field] is JsonValue value
&& value.TryGetValue<string>(out var text)
&& !rule.Pattern.IsMatch(text)
)
{
throw new InvalidDataException(
$"Def '{defName}' ({typeKey}) field '{rule.Field}'=\"{text}\" violates {rule.Description}."
);
}
}
}
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{
if (!seen.Add(defName))
{
throw new InvalidDataException(
$"Cyclic def inheritance involving '{defName}' ({entry.Key})."
);
}
if (!entry.Raw.TryGetValue(defName, out var node))
{
throw new InvalidDataException($"Unknown parent def '{defName}' ({entry.Key}).");
}
var parentName = node["parent"]?.GetValue<string>();
if (parentName is null)
{
return (JsonObject)node.DeepClone();
}
var merged = MergeChain(entry, parentName, seen);
merged.Remove("abstract"); // абстрактность не наследуется
merged.Remove("defName");
foreach (var (key, value) in node)
{
merged[key] = value?.DeepClone();
}
return merged;
}
}
@@ -0,0 +1,163 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MrGameEng.Mods;
/// <summary>
/// Keyed localization strings loaded from mods: <c>Languages/&lt;code&gt;/**/*.json</c>,
/// each file a flat string-to-string map. Later mods override earlier ones key by key.
/// Lookup falls back from the current language to the default one; a missing key returns
/// the key itself, so untranslated strings are visible instead of crashing.
/// </summary>
public sealed class LanguageManager
{
private readonly Dictionary<string, Dictionary<string, string>> _languages = new(
StringComparer.OrdinalIgnoreCase
);
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
public LanguageManager(string defaultLanguage = "en")
{
DefaultLanguage = defaultLanguage;
CurrentLanguage = defaultLanguage;
}
/// <summary>Fallback language code.</summary>
public string DefaultLanguage { get; }
/// <summary>Active language code. Change with <see cref="SetLanguage"/>.</summary>
public string CurrentLanguage { get; private set; }
/// <summary>Increments whenever loaded strings or the active language change — UI rebuilds on it.</summary>
public int Revision { get; private set; }
/// <summary>Language codes that have at least one loaded string, sorted.</summary>
public IReadOnlyList<string> AvailableLanguages =>
_languages.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Loads (merges in) language files of <paramref name="mods"/> in load order.</summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var languagesDir = mod.ContentPath("Languages");
if (!Directory.Exists(languagesDir))
{
continue;
}
foreach (
var languageDir in Directory
.EnumerateDirectories(languagesDir)
.OrderBy(d => d, StringComparer.Ordinal)
)
{
var code = Path.GetFileName(languageDir);
if (!_languages.TryGetValue(code, out var strings))
{
strings = new Dictionary<string, string>(StringComparer.Ordinal);
_languages.Add(code, strings);
}
var files = Directory
.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file, strings);
}
}
}
Revision++;
}
/// <summary>
/// Switches the active language. Returns false (and keeps the current one) when no
/// strings are loaded for <paramref name="code"/>.
/// </summary>
public bool SetLanguage(string code)
{
if (!_languages.ContainsKey(code))
{
return false;
}
CurrentLanguage = code;
Revision++;
return true;
}
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
public string Get(string key)
{
if (
_languages.TryGetValue(CurrentLanguage, out var current)
&& current.TryGetValue(key, out var value)
)
{
return value;
}
if (
_languages.TryGetValue(DefaultLanguage, out var fallback)
&& fallback.TryGetValue(key, out value)
)
{
return value;
}
return key;
}
/// <summary>Formats the string for <paramref name="key"/> with <paramref name="args"/> (invariant culture).</summary>
public string Format(string key, params object[] args) =>
string.Format(CultureInfo.InvariantCulture, Get(key), args);
private static void LoadFile(Mod mod, string file, Dictionary<string, string> strings)
{
JsonNode root;
try
{
root =
JsonNode.Parse(
File.ReadAllText(file),
documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}
) ?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
if (root is not JsonObject map)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object."
);
}
foreach (var (key, value) in map)
{
if (
value is not JsonValue jsonValue
|| jsonValue.GetValueKind() != JsonValueKind.String
)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string."
);
}
strings[key] = jsonValue.GetValue<string>(); // поздний мод переопределяет ключ
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace MrGameEng.Mods;
/// <summary>One discovered mod: its metadata and content directories on disk.</summary>
public sealed class Mod
{
internal Mod(ModInfo info, string rootPath)
{
Info = info;
RootPath = rootPath;
}
/// <summary>Metadata from <c>About/About.json</c>.</summary>
public ModInfo Info { get; }
/// <summary>Absolute path of the mod's root directory.</summary>
public string RootPath { get; }
/// <summary>Unique mod id (shortcut for <c>Info.Id</c>).</summary>
public string Id => Info.Id;
/// <summary>
/// Absolute path of a content folder inside the mod (e.g. <c>Defs</c>, <c>Textures</c>,
/// <c>Languages</c>). The folder is not required to exist.
/// </summary>
public string ContentPath(string folder) => Path.Combine(RootPath, folder);
/// <inheritdoc />
public override string ToString() => $"{Id} {Info.Version}".TrimEnd();
}
@@ -0,0 +1,76 @@
namespace MrGameEng.Mods;
/// <summary>A file contributed by a mod, addressed by its content-relative path.</summary>
/// <param name="RelativePath">Path relative to the content folder, forward slashes.</param>
/// <param name="FullPath">Absolute path of the winning file on disk.</param>
/// <param name="Mod">The mod that contributed the file.</param>
public readonly record struct ModFile(string RelativePath, string FullPath, Mod Mod);
/// <summary>
/// Merged view of one content folder (e.g. <c>Textures</c>) across the active mods in load
/// order: a later mod shipping a file under the same relative path overrides the earlier
/// one. Paths use forward slashes and match case-insensitively (Windows-friendly content).
/// </summary>
public sealed class ModContentTree
{
private readonly Dictionary<string, ModFile> _files;
private ModContentTree(Dictionary<string, ModFile> files, IReadOnlyList<ModFile> ordered)
{
_files = files;
Files = ordered;
}
/// <summary>Winning files, sorted by relative path — deterministic for identical mod sets.</summary>
public IReadOnlyList<ModFile> Files { get; }
/// <summary>Returns the winning file for <paramref name="relativePath"/>.</summary>
public bool TryGet(string relativePath, out ModFile file) =>
_files.TryGetValue(Normalize(relativePath), out file);
/// <summary>
/// Builds the merged tree of <paramref name="contentFolder"/> over <paramref name="mods"/>
/// (in load order). With <paramref name="extensions"/> only matching files are included
/// (e.g. <c>".png"</c>); without them, every file.
/// </summary>
public static ModContentTree Build(
IReadOnlyList<Mod> mods,
string contentFolder,
params string[] extensions
)
{
var files = new Dictionary<string, ModFile>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in mods)
{
var root = mod.ContentPath(contentFolder);
if (!Directory.Exists(root))
{
continue;
}
foreach (
var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
)
{
if (
extensions.Length > 0
&& !extensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
var relative = Normalize(Path.GetRelativePath(root, fullPath));
files[relative] = new ModFile(relative, fullPath, mod); // поздний мод побеждает
}
}
var ordered = files.Values.OrderBy(f => f.RelativePath, StringComparer.Ordinal).ToList();
return new ModContentTree(files, ordered);
}
private static string Normalize(string path) => path.Replace('\\', '/');
}
+38
View File
@@ -0,0 +1,38 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MrGameEng.Mods;
/// <summary>
/// Mod metadata loaded from <c>About/About.json</c> in the mod's root directory.
/// </summary>
public sealed class ModInfo
{
/// <summary>JSON options shared by all mod content readers (camelCase, comments and trailing commas allowed).</summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
public string Id { get; init; } = "";
/// <summary>Human-readable mod name.</summary>
public string Name { get; init; } = "";
/// <summary>Mod author.</summary>
public string Author { get; init; } = "";
/// <summary>Mod version string (informational).</summary>
public string Version { get; init; } = "";
/// <summary>Short description shown in mod lists.</summary>
public string Description { get; init; } = "";
/// <summary>Ids of mods that must be active and load before this one.</summary>
public List<string> Dependencies { get; init; } = [];
}
+166
View File
@@ -0,0 +1,166 @@
using System.Text.Json;
using MrGameEng.Core;
namespace MrGameEng.Mods;
/// <summary>
/// Discovers mods — subdirectories of a mods root containing <c>About/About.json</c> —
/// and resolves a deterministic load order: dependencies first, ties broken
/// alphabetically by id. Later mods override earlier ones in every content system
/// (defs, textures, languages).
/// </summary>
public static class ModLoader
{
/// <summary>
/// Walks up from <paramref name="startDirectory"/> looking for a <c>Mods</c> folder.
/// Lets dev builds run from <c>bin/…</c> while shipped builds keep <c>Mods</c> next to
/// the executable. Returns null when no such folder exists on the path to the root.
/// </summary>
public static string? FindModsRoot(string startDirectory)
{
for (
var dir = new DirectoryInfo(Path.GetFullPath(startDirectory));
dir is not null;
dir = dir.Parent
)
{
var candidate = Path.Combine(dir.FullName, "Mods");
if (Directory.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Loads mods under <paramref name="modsRoot"/> in dependency order. With
/// <paramref name="activeIds"/> only that subset is loaded (its dependencies must be
/// included); without it every discovered mod is active.
/// </summary>
public static IReadOnlyList<Mod> Load(string modsRoot, IEnumerable<string>? activeIds = null)
{
if (!Directory.Exists(modsRoot))
{
throw new DirectoryNotFoundException($"Mods root not found: '{modsRoot}'.");
}
var discovered = Discover(modsRoot);
List<Mod> active;
if (activeIds is null)
{
active = discovered.Values.ToList();
}
else
{
active = [];
foreach (var id in activeIds)
{
if (!discovered.TryGetValue(id, out var mod))
{
throw new InvalidDataException(
$"Active mod '{id}' is not installed under '{modsRoot}'."
);
}
active.Add(mod);
}
}
var ordered = SortByDependencies(active);
Log.Info($"Mods loaded: {string.Join(", ", ordered)}");
return ordered;
}
private static SortedDictionary<string, Mod> Discover(string modsRoot)
{
var discovered = new SortedDictionary<string, Mod>(StringComparer.Ordinal);
foreach (var dir in Directory.EnumerateDirectories(modsRoot))
{
var aboutPath = Path.Combine(dir, "About", "About.json");
if (!File.Exists(aboutPath))
{
continue; // не мод — служебная папка
}
ModInfo info;
try
{
info =
JsonSerializer.Deserialize<ModInfo>(
File.ReadAllText(aboutPath),
ModInfo.JsonOptions
) ?? throw new InvalidDataException("About.json deserialized to null.");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid mod metadata '{aboutPath}': {exception.Message}",
exception
);
}
if (string.IsNullOrWhiteSpace(info.Id))
{
throw new InvalidDataException($"Mod at '{dir}' has an empty id in About.json.");
}
if (discovered.TryGetValue(info.Id, out var existing))
{
throw new InvalidDataException(
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'."
);
}
discovered.Add(info.Id, new Mod(info, dir));
}
return discovered;
}
private static List<Mod> SortByDependencies(List<Mod> active)
{
var byId = active.ToDictionary(m => m.Id, StringComparer.Ordinal);
var ordered = new List<Mod>(active.Count);
var state = new Dictionary<string, bool>(StringComparer.Ordinal); // false = в обработке, true = готов
void Visit(Mod mod)
{
if (state.TryGetValue(mod.Id, out var done))
{
if (!done)
{
throw new InvalidDataException($"Cyclic mod dependency involving '{mod.Id}'.");
}
return;
}
state[mod.Id] = false;
foreach (var dependency in mod.Info.Dependencies)
{
if (!byId.TryGetValue(dependency, out var parent))
{
throw new InvalidDataException(
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active."
);
}
Visit(parent);
}
state[mod.Id] = true;
ordered.Add(mod);
}
// Обход в алфавитном порядке id — итоговый порядок детерминирован.
foreach (var mod in active.OrderBy(m => m.Id, StringComparer.Ordinal))
{
Visit(mod);
}
return ordered;
}
}
@@ -1,14 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Atlases.Tests" />
<InternalsVisibleTo Include="MrGameEng.Content.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FontStashSharp.MonoGame" />
<PackageReference Include="StbImageSharp" />
<PackageReference Include="StbImageWriteSharp" />
</ItemGroup>
@@ -16,7 +16,5 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
</ItemGroup>
</Project>
+89
View File
@@ -0,0 +1,89 @@
namespace MrGameEng.Core;
/// <summary>
/// In-game calendar layered over <see cref="GameClock"/>: turns scaled elapsed time into whole
/// days and a fraction-of-day (time of day). One in-game day spans <see cref="SecondsPerDay"/>
/// seconds of scaled clock time, so pausing or changing <see cref="GameClock.TimeScale"/> slows
/// or stops the calendar automatically. Pure read-side, GPU-free and deterministic; registered as
/// a service via <see cref="CalendarEngineExtensions.UseCalendar"/>.
/// </summary>
public sealed class Calendar
{
private readonly GameClock _clock;
private readonly double _startDay;
private float _secondsPerDay;
/// <summary>
/// Creates a calendar reading <paramref name="clock"/>; one day spans
/// <paramref name="secondsPerDay"/> seconds of scaled time (must be positive).
/// <paramref name="startDay"/> offsets the calendar by (fractional) days at clock 0 — e.g.
/// <c>7.0 / 24</c> starts the world at 07:00 instead of midnight. It shifts the time of day and
/// the day/night phase that reads <see cref="DayProgress"/>, without touching the clock itself.
/// </summary>
public Calendar(GameClock clock, float secondsPerDay, double startDay = 0.0)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
SecondsPerDay = secondsPerDay;
_startDay = startDay;
}
/// <summary>Scaled seconds per in-game day. Must be positive; raising it slows the calendar.</summary>
public float SecondsPerDay
{
get => _secondsPerDay;
set =>
_secondsPerDay =
value > 0f
? value
: throw new ArgumentOutOfRangeException(
nameof(value),
"Seconds per day must be positive."
);
}
/// <summary>Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4), incl. the start offset.</summary>
public double TotalDays => _clock.TotalTime / _secondsPerDay + _startDay;
/// <summary>The current day number, counting from 1.</summary>
public int Day => (int)TotalDays + 1;
/// <summary>Progress through the current day in <c>[0, 1)</c> — 0 at dawn, ~0.5 at midday.</summary>
public float DayProgress
{
get
{
var days = TotalDays;
return (float)(days - Math.Floor(days));
}
}
/// <summary>Minutes elapsed in the current day, 01439 (a day is 24×60 in-game minutes).</summary>
public int MinuteOfDay => (int)(DayProgress * 1440f) % 1440;
/// <summary>Hour of the current day, 023.</summary>
public int Hour => MinuteOfDay / 60;
/// <summary>Minute of the current hour, 059.</summary>
public int Minute => MinuteOfDay % 60;
}
/// <summary>Wires the in-game calendar into the engine.</summary>
public static class CalendarEngineExtensions
{
/// <summary>
/// Creates a <see cref="Calendar"/> bound to the context's clock and registers it as a
/// service. Call once per world. <paramref name="secondsPerDay"/> is the scaled-time length
/// of one in-game day (must be positive); <paramref name="startDay"/> offsets the starting
/// time of day in fractional days (e.g. <c>7.0 / 24</c> begins the world at 07:00).
/// </summary>
public static Calendar UseCalendar(
this EngineContext context,
float secondsPerDay,
double startDay = 0.0
)
{
var calendar = new Calendar(context.Clock, secondsPerDay, startDay);
context.Services.Add(calendar);
return calendar;
}
}
+147
View File
@@ -0,0 +1,147 @@
namespace MrGameEng.Core;
/// <summary>The four seasons, in calendar order from the start of the year.</summary>
public enum Season
{
/// <summary>First quarter of the year — warming.</summary>
Spring,
/// <summary>Second quarter — warmest.</summary>
Summer,
/// <summary>Third quarter — cooling.</summary>
Autumn,
/// <summary>Last quarter — coldest.</summary>
Winter,
}
/// <summary>
/// Tuning for <see cref="Climate"/>. A year spans <see cref="DaysPerYear"/> in-game days; temperature
/// follows a seasonal cosine peaking on <see cref="WarmestDay"/>, plus a daily swing (cooler at night).
/// </summary>
public readonly record struct ClimateSettings
{
/// <summary>In-game days in one year (e.g. 60 = four 15-day seasons).</summary>
public int DaysPerYear { get; init; }
/// <summary>Yearly mean temperature.</summary>
public float MeanTemperature { get; init; }
/// <summary>Peak deviation from the mean across the seasons (summer high / winter low).</summary>
public float SeasonalAmplitude { get; init; }
/// <summary>Peak deviation from the seasonal mean across one day (warmer at noon, cooler at night).</summary>
public float DailyAmplitude { get; init; }
/// <summary>Day of the year (0-based) with the highest temperature; defaults to mid-summer.</summary>
public int WarmestDay { get; init; }
/// <summary>
/// Day of the year (0-based) that the calendar's day 0 maps to. Shifts the whole seasonal phase
/// (season and temperature together) so a world can begin in a chosen part of the year — e.g. a
/// warm late spring instead of the cold turn of the year. Defaults to 0 (year begins at day 0).
/// </summary>
public int StartDayOfYear { get; init; }
/// <summary>Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.</summary>
public static ClimateSettings Default =>
new()
{
DaysPerYear = 60,
MeanTemperature = 12f,
SeasonalAmplitude = 14f,
DailyAmplitude = 5f,
WarmestDay = 22, // ~middle of the summer quarter of a 60-day year
};
}
/// <summary>
/// Continuous climate layered over <see cref="Calendar"/>: a temperature that varies smoothly with the
/// season and the time of day, plus the current season and year. The whole curve is derived from the
/// calendar's elapsed time, so it slows or stops with the game clock. Pure read-side, GPU-free and
/// deterministic; registered as a service via <see cref="ClimateEngineExtensions.UseClimate"/>.
/// </summary>
public sealed class Climate
{
private readonly Calendar _calendar;
private readonly ClimateSettings _settings;
/// <summary>Creates a climate reading <paramref name="calendar"/> with the given <paramref name="settings"/>.</summary>
public Climate(Calendar calendar, ClimateSettings settings)
{
_calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
if (settings.DaysPerYear <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(settings),
"Days per year must be positive."
);
}
_settings = settings;
}
/// <summary>In-game days per year.</summary>
public int DaysPerYear => _settings.DaysPerYear;
/// <summary>Elapsed days shifted by <see cref="ClimateSettings.StartDayOfYear"/> — the seasonal clock.</summary>
private double YearDays => _calendar.TotalDays + _settings.StartDayOfYear;
/// <summary>Continuous position within the current year in <c>[0, 1)</c>.</summary>
public double YearProgress
{
get
{
var years = YearDays / _settings.DaysPerYear;
return years - Math.Floor(years);
}
}
/// <summary>The current year, counting from 1.</summary>
public int Year => (int)(YearDays / _settings.DaysPerYear) + 1;
/// <summary>Day within the current year, 0-based.</summary>
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
/// <summary>The current season, derived from the quarter of the year.</summary>
public Season Season => (Season)Math.Clamp((int)(YearProgress * 4.0), 0, 3);
/// <summary>Current temperature: seasonal cosine peaking on the warmest day, plus a daily swing.</summary>
public float Temperature
{
get
{
var warmFraction = _settings.WarmestDay / (float)_settings.DaysPerYear;
var seasonal =
_settings.MeanTemperature
+ _settings.SeasonalAmplitude
* MathF.Cos(MathF.Tau * ((float)YearProgress - warmFraction));
var daily = _settings.DailyAmplitude * (2f * Daylight() - 1f);
return seasonal + daily;
}
}
// Daytime factor 0..1 (0 at midnight, 1 at noon). Mirrors the day/night curve without a
// Graphics dependency — Core owns the daily temperature swing.
private float Daylight()
{
var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress);
return value > 0f ? value : 0f;
}
}
/// <summary>Wires the climate model into the engine.</summary>
public static class ClimateEngineExtensions
{
/// <summary>
/// Creates a <see cref="Climate"/> bound to the context's <see cref="Calendar"/> service and
/// registers it. Call once per world, after <see cref="CalendarEngineExtensions.UseCalendar"/>.
/// </summary>
public static Climate UseClimate(this EngineContext context, ClimateSettings settings)
{
var climate = new Climate(context.Services.Get<Calendar>(), settings);
context.Services.Add(climate);
return climate;
}
}
+12 -18
View File
@@ -1,10 +1,11 @@
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
/// <summary>
/// Root object handed to scenes and systems: time, scene manager, services and graphics device.
/// Created by <see cref="GameHost"/>; can also be created standalone for headless tests.
/// Root object handed to scenes and systems: time, scene manager and services.
/// Created by a host (the MonoGame game host or <see cref="HeadlessHost"/>); can also be
/// created standalone for unit tests. Platform resources such as the graphics device are
/// published through <see cref="Services"/> by hosts that have them — the core itself has
/// no platform dependencies.
/// </summary>
public sealed class EngineContext
{
@@ -17,23 +18,16 @@ public sealed class EngineContext
/// <summary>Registry of module services (input, audio, assets, …).</summary>
public ServiceRegistry Services { get; } = new();
/// <summary>
/// The graphics device. Available once the host is initialized;
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context).");
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
private GraphicsDevice? _graphicsDevice;
/// <summary>Creates a context. Games normally never create one themselves — <see cref="GameHost"/> does.</summary>
/// <summary>Creates a context. Games normally never create one themselves — the host does.</summary>
public EngineContext()
{
Scenes = new SceneManager(this);
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
/// <summary>
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services.
/// Instances in <paramref name="except"/> (the host itself, platform resources the host
/// disposes on its own) are skipped. Called by hosts on shutdown.
/// </summary>
internal void DisposeOwnedResources(params object[] except) => Services.DisposeServices(except);
}
+60
View File
@@ -0,0 +1,60 @@
namespace MrGameEng.Formulas;
/// <summary>
/// A compiled arithmetic expression — the keystone of the data-driven gene system. A formula is
/// parsed once from a string in a def (e.g. a gene effect on a trait) into a delegate tree, then
/// evaluated many times against an <see cref="IFormulaContext"/> that supplies variable values
/// (gene values, environment readings, other traits). Evaluation is deterministic and
/// allocation-free; all the work happens in <see cref="Compile"/>.
///
/// <para>Grammar: numbers, variables, the constants <c>pi</c>/<c>tau</c>/<c>e</c>, operators
/// <c>+ - * / %</c>, comparisons <c>&lt; &gt; &lt;= &gt;= == !=</c>, logical <c>&amp;&amp; || !</c>,
/// the ternary <c>cond ? a : b</c>, and functions <c>abs sign floor ceil round sqrt exp log sin cos
/// tan min max pow clamp lerp step</c>. Comparisons and logical operators yield <c>1</c>/<c>0</c>.</para>
/// </summary>
public sealed class Formula
{
private static readonly IFormulaContext Empty = new EmptyContext();
private readonly Func<IFormulaContext, float> _root;
private Formula(string source, Func<IFormulaContext, float> root)
{
Source = source;
_root = root;
}
/// <summary>The original expression text this formula was compiled from.</summary>
public string Source { get; }
/// <summary>
/// Parses and compiles <paramref name="expression"/>. Throws <see cref="FormulaException"/> on any
/// lexical or syntactic error, with the offending position.
/// </summary>
public static Formula Compile(string expression)
{
ArgumentNullException.ThrowIfNull(expression);
var tokens = FormulaLexer.Tokenize(expression);
var root = new FormulaParser(tokens).ParseProgram();
return new Formula(expression, root);
}
/// <summary>Evaluates the formula, resolving variables through <paramref name="context"/>.</summary>
public float Evaluate(IFormulaContext context)
{
ArgumentNullException.ThrowIfNull(context);
return _root(context);
}
/// <summary>
/// Evaluates a formula that references no variables. Throws <see cref="FormulaException"/> if it
/// turns out to reference one.
/// </summary>
public float Evaluate() => _root(Empty);
private sealed class EmptyContext : IFormulaContext
{
public float Resolve(string name) =>
throw new FormulaException($"No context to resolve variable '{name}'.");
}
}
@@ -0,0 +1,35 @@
namespace MrGameEng.Formulas;
/// <summary>
/// Supplies variable values to a compiled <see cref="Formula"/>. A context maps a variable name
/// (a gene value, an environment reading, another trait…) to a number; the formula engine itself
/// is data-agnostic, so any consumer can back this with whatever lookup it owns.
/// </summary>
public interface IFormulaContext
{
/// <summary>
/// Returns the value bound to <paramref name="name"/>. Throw (e.g. <see cref="FormulaException"/>)
/// if the name is unknown — the engine does not invent a default.
/// </summary>
float Resolve(string name);
/// <summary>
/// Returns the values of every variable whose name satisfies <paramref name="matches"/> — the
/// backing for the group functions (<c>gsum</c>, <c>gavg</c>, …) that aggregate over a name
/// pattern, e.g. all <c>leaf_*</c> genes. Contexts with no enumerable variables return nothing.
/// </summary>
IEnumerable<float> ResolveMatching(Func<string, bool> matches) => [];
}
/// <summary>An <see cref="IFormulaContext"/> backed by a lookup delegate — handy for tests and ad-hoc use.</summary>
public sealed class DelegateFormulaContext : IFormulaContext
{
private readonly Func<string, float> _resolve;
/// <summary>Wraps <paramref name="resolve"/>; it is called once per variable reference per evaluation.</summary>
public DelegateFormulaContext(Func<string, float> resolve) =>
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
/// <inheritdoc />
public float Resolve(string name) => _resolve(name);
}
@@ -0,0 +1,12 @@
namespace MrGameEng.Formulas;
/// <summary>
/// Thrown when a <see cref="Formula"/> cannot be lexed or parsed, or when a compiled formula
/// references a variable the context cannot resolve at evaluation time.
/// </summary>
public sealed class FormulaException : Exception
{
/// <summary>Creates the exception with a human-readable <paramref name="message"/>.</summary>
public FormulaException(string message)
: base(message) { }
}
@@ -0,0 +1,90 @@
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// The built-in function set available inside formulas. Each entry validates its argument count at
/// compile time and returns a <see cref="Node"/> that evaluates its operands then the math. Kept
/// deterministic and side-effect-free so formulas stay pure.
/// </summary>
internal static class FormulaFunctions
{
public static Node Build(string name, List<Node> args)
{
switch (name)
{
case "abs":
return Unary(name, args, MathF.Abs);
case "sign":
return Unary(name, args, x => MathF.Sign(x));
case "floor":
return Unary(name, args, MathF.Floor);
case "ceil":
return Unary(name, args, MathF.Ceiling);
case "round":
return Unary(name, args, MathF.Round);
case "sqrt":
return Unary(name, args, MathF.Sqrt);
case "exp":
return Unary(name, args, MathF.Exp);
case "log":
return args.Count == 2
? Binary(name, args, MathF.Log)
: Unary(name, args, MathF.Log);
case "sin":
return Unary(name, args, MathF.Sin);
case "cos":
return Unary(name, args, MathF.Cos);
case "tan":
return Unary(name, args, MathF.Tan);
case "min":
return Binary(name, args, MathF.Min);
case "max":
return Binary(name, args, MathF.Max);
case "pow":
return Binary(name, args, MathF.Pow);
case "clamp":
return Ternary(name, args, (x, lo, hi) => Math.Clamp(x, lo, hi));
case "lerp":
return Ternary(name, args, (a, b, t) => a + (b - a) * t);
case "step":
return Binary(name, args, (edge, x) => x < edge ? 0f : 1f);
default:
throw new FormulaException($"Unknown function '{name}'.");
}
}
private static Node Unary(string name, List<Node> args, Func<float, float> op)
{
Require(name, args, 1);
var a = args[0];
return ctx => op(a(ctx));
}
private static Node Binary(string name, List<Node> args, Func<float, float, float> op)
{
Require(name, args, 2);
var a = args[0];
var b = args[1];
return ctx => op(a(ctx), b(ctx));
}
private static Node Ternary(string name, List<Node> args, Func<float, float, float, float> op)
{
Require(name, args, 3);
var a = args[0];
var b = args[1];
var c = args[2];
return ctx => op(a(ctx), b(ctx), c(ctx));
}
private static void Require(string name, List<Node> args, int count)
{
if (args.Count != count)
{
throw new FormulaException(
$"Function '{name}' expects {count} argument(s) but got {args.Count}."
);
}
}
}
@@ -0,0 +1,98 @@
using System.Text.RegularExpressions;
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// The group functions — <c>gsum</c>, <c>gcount</c>, <c>gavg</c>, <c>gmin</c>, <c>gmax</c> — which
/// aggregate over every context variable whose name matches a regex literal, e.g.
/// <c>gsum('leaf_.*')</c> sums all <c>leaf_*</c> genes. The pattern is a string literal compiled to a
/// <see cref="Regex"/> once at parse time; aggregation reads <see cref="IFormulaContext.ResolveMatching"/>.
/// </summary>
internal static class FormulaGroups
{
private static readonly HashSet<string> Names = new(StringComparer.Ordinal)
{
"gsum",
"gcount",
"gavg",
"gmin",
"gmax",
};
public static bool IsGroupFunction(string name) => Names.Contains(name);
public static Node Build(string name, string pattern)
{
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.CultureInvariant);
}
catch (ArgumentException error)
{
throw new FormulaException($"Invalid regex '{pattern}': {error.Message}");
}
bool Match(string variable) => regex.IsMatch(variable);
return name switch
{
"gsum" => ctx => Aggregate(ctx.ResolveMatching(Match), sum: true),
"gavg" => ctx => Aggregate(ctx.ResolveMatching(Match), average: true),
"gcount" => ctx => Count(ctx.ResolveMatching(Match)),
"gmin" => ctx => Extreme(ctx.ResolveMatching(Match), max: false),
"gmax" => ctx => Extreme(ctx.ResolveMatching(Match), max: true),
_ => throw new FormulaException($"Unknown group function '{name}'."),
};
}
private static float Aggregate(
IEnumerable<float> values,
bool sum = false,
bool average = false
)
{
var total = 0f;
var count = 0;
foreach (var value in values)
{
total += value;
count++;
}
if (average)
{
return count == 0 ? 0f : total / count;
}
return total; // sum (count==0 → 0)
}
private static float Count(IEnumerable<float> values)
{
var count = 0;
foreach (var _ in values)
{
count++;
}
return count;
}
private static float Extreme(IEnumerable<float> values, bool max)
{
var has = false;
var best = 0f;
foreach (var value in values)
{
if (!has || (max ? value > best : value < best))
{
best = value;
}
has = true;
}
return best; // empty → 0
}
}
+239
View File
@@ -0,0 +1,239 @@
using System.Globalization;
namespace MrGameEng.Formulas;
internal enum TokenType
{
Number,
Identifier,
String,
Plus,
Minus,
Star,
Slash,
Percent,
LParen,
RParen,
Comma,
Less,
Greater,
LessEqual,
GreaterEqual,
EqualEqual,
NotEqual,
And,
Or,
Not,
Question,
Colon,
End,
}
internal readonly struct Token(TokenType type, int position, float number = 0f, string text = "")
{
public TokenType Type { get; } = type;
public int Position { get; } = position;
public float Number { get; } = number;
public string Text { get; } = text;
}
/// <summary>
/// Turns a formula string into a flat token list. Pure and allocation-light; recognizes numbers,
/// identifiers, the arithmetic/comparison/logical operators and the punctuation the parser needs.
/// </summary>
internal static class FormulaLexer
{
public static List<Token> Tokenize(string source)
{
var tokens = new List<Token>();
var i = 0;
while (i < source.Length)
{
var c = source[i];
if (char.IsWhiteSpace(c))
{
i++;
continue;
}
if (
char.IsDigit(c)
|| (c == '.' && i + 1 < source.Length && char.IsDigit(source[i + 1]))
)
{
var start = i;
while (i < source.Length && (char.IsDigit(source[i]) || source[i] == '.'))
{
i++;
}
var span = source.AsSpan(start, i - start);
if (
!float.TryParse(
span,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var value
)
)
{
throw new FormulaException(
$"Invalid number '{span.ToString()}' at position {start}."
);
}
tokens.Add(new Token(TokenType.Number, start, value));
continue;
}
if (char.IsLetter(c) || c == '_')
{
var start = i;
while (i < source.Length && (char.IsLetterOrDigit(source[i]) || source[i] == '_'))
{
i++;
}
tokens.Add(
new Token(TokenType.Identifier, start, text: source.Substring(start, i - start))
);
continue;
}
if (c == '\'')
{
var open = i;
var start = ++i;
while (i < source.Length && source[i] != '\'')
{
i++;
}
if (i >= source.Length)
{
throw new FormulaException($"Unterminated string at position {open}.");
}
tokens.Add(
new Token(TokenType.String, open, text: source.Substring(start, i - start))
);
i++; // пропускаем закрывающую кавычку
continue;
}
var pos = i;
switch (c)
{
case '+':
tokens.Add(new Token(TokenType.Plus, pos));
i++;
break;
case '-':
tokens.Add(new Token(TokenType.Minus, pos));
i++;
break;
case '*':
tokens.Add(new Token(TokenType.Star, pos));
i++;
break;
case '/':
tokens.Add(new Token(TokenType.Slash, pos));
i++;
break;
case '%':
tokens.Add(new Token(TokenType.Percent, pos));
i++;
break;
case '(':
tokens.Add(new Token(TokenType.LParen, pos));
i++;
break;
case ')':
tokens.Add(new Token(TokenType.RParen, pos));
i++;
break;
case ',':
tokens.Add(new Token(TokenType.Comma, pos));
i++;
break;
case '?':
tokens.Add(new Token(TokenType.Question, pos));
i++;
break;
case ':':
tokens.Add(new Token(TokenType.Colon, pos));
i++;
break;
case '<':
i = AddMaybeEqual(tokens, source, i, TokenType.LessEqual, TokenType.Less);
break;
case '>':
i = AddMaybeEqual(tokens, source, i, TokenType.GreaterEqual, TokenType.Greater);
break;
case '=':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.EqualEqual, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '==' at position {pos}.");
case '!':
if (Next(source, i) == '=')
{
tokens.Add(new Token(TokenType.NotEqual, pos));
i += 2;
break;
}
tokens.Add(new Token(TokenType.Not, pos));
i++;
break;
case '&':
if (Next(source, i) == '&')
{
tokens.Add(new Token(TokenType.And, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '&&' at position {pos}.");
case '|':
if (Next(source, i) == '|')
{
tokens.Add(new Token(TokenType.Or, pos));
i += 2;
break;
}
throw new FormulaException($"Expected '||' at position {pos}.");
default:
throw new FormulaException($"Unexpected character '{c}' at position {pos}.");
}
}
tokens.Add(new Token(TokenType.End, source.Length));
return tokens;
}
private static int AddMaybeEqual(
List<Token> tokens,
string source,
int i,
TokenType withEqual,
TokenType plain
)
{
if (Next(source, i) == '=')
{
tokens.Add(new Token(withEqual, i));
return i + 2;
}
tokens.Add(new Token(plain, i));
return i + 1;
}
private static char Next(string source, int i) => i + 1 < source.Length ? source[i + 1] : '\0';
}
@@ -0,0 +1,287 @@
using Node = System.Func<MrGameEng.Formulas.IFormulaContext, float>;
namespace MrGameEng.Formulas;
/// <summary>
/// Recursive-descent parser that compiles a token list straight into a tree of <see cref="Node"/>
/// delegates. Parsing (and therefore all closure allocation) happens once; evaluating the returned
/// node is allocation-free. Precedence, low to high: ternary, <c>||</c>, <c>&amp;&amp;</c>, equality,
/// comparison, additive, multiplicative, unary, primary.
/// </summary>
internal sealed class FormulaParser(List<Token> tokens)
{
private const float True = 1f;
private const float False = 0f;
private int _pos;
public Node ParseProgram()
{
var node = ParseTernary();
Expect(TokenType.End);
return node;
}
private Node ParseTernary()
{
var condition = ParseOr();
if (!Match(TokenType.Question))
{
return condition;
}
var whenTrue = ParseTernary();
Expect(TokenType.Colon);
var whenFalse = ParseTernary();
return ctx => condition(ctx) != False ? whenTrue(ctx) : whenFalse(ctx);
}
private Node ParseOr()
{
var left = ParseAnd();
while (Match(TokenType.Or))
{
var right = ParseAnd();
var l = left;
left = ctx => l(ctx) != False || right(ctx) != False ? True : False;
}
return left;
}
private Node ParseAnd()
{
var left = ParseEquality();
while (Match(TokenType.And))
{
var right = ParseEquality();
var l = left;
left = ctx => l(ctx) != False && right(ctx) != False ? True : False;
}
return left;
}
private Node ParseEquality()
{
var left = ParseComparison();
while (true)
{
if (Match(TokenType.EqualEqual))
{
var right = ParseComparison();
var l = left;
left = ctx => l(ctx) == right(ctx) ? True : False;
}
else if (Match(TokenType.NotEqual))
{
var right = ParseComparison();
var l = left;
left = ctx => l(ctx) != right(ctx) ? True : False;
}
else
{
return left;
}
}
}
private Node ParseComparison()
{
var left = ParseAdditive();
while (true)
{
if (Match(TokenType.Less))
{
left = Compare(left, ParseAdditive(), (a, b) => a < b);
}
else if (Match(TokenType.LessEqual))
{
left = Compare(left, ParseAdditive(), (a, b) => a <= b);
}
else if (Match(TokenType.Greater))
{
left = Compare(left, ParseAdditive(), (a, b) => a > b);
}
else if (Match(TokenType.GreaterEqual))
{
left = Compare(left, ParseAdditive(), (a, b) => a >= b);
}
else
{
return left;
}
}
}
private Node ParseAdditive()
{
var left = ParseMultiplicative();
while (true)
{
if (Match(TokenType.Plus))
{
var right = ParseMultiplicative();
var l = left;
left = ctx => l(ctx) + right(ctx);
}
else if (Match(TokenType.Minus))
{
var right = ParseMultiplicative();
var l = left;
left = ctx => l(ctx) - right(ctx);
}
else
{
return left;
}
}
}
private Node ParseMultiplicative()
{
var left = ParseUnary();
while (true)
{
if (Match(TokenType.Star))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) * right(ctx);
}
else if (Match(TokenType.Slash))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) / right(ctx);
}
else if (Match(TokenType.Percent))
{
var right = ParseUnary();
var l = left;
left = ctx => l(ctx) % right(ctx);
}
else
{
return left;
}
}
}
private Node ParseUnary()
{
if (Match(TokenType.Minus))
{
var operand = ParseUnary();
return ctx => -operand(ctx);
}
if (Match(TokenType.Plus))
{
return ParseUnary();
}
if (Match(TokenType.Not))
{
var operand = ParseUnary();
return ctx => operand(ctx) != False ? False : True;
}
return ParsePrimary();
}
private Node ParsePrimary()
{
var token = Current;
if (Match(TokenType.Number))
{
var value = token.Number;
return _ => value;
}
if (Match(TokenType.LParen))
{
var inner = ParseTernary();
Expect(TokenType.RParen);
return inner;
}
if (Match(TokenType.Identifier))
{
return Peek(TokenType.LParen) ? ParseCall(token.Text) : ParseName(token.Text);
}
throw new FormulaException($"Unexpected token at position {token.Position}.");
}
private Node ParseName(string name)
{
switch (name)
{
case "pi":
return _ => MathF.PI;
case "tau":
return _ => MathF.Tau;
case "e":
return _ => MathF.E;
default:
return ctx => ctx.Resolve(name);
}
}
private Node ParseCall(string name)
{
Expect(TokenType.LParen);
if (FormulaGroups.IsGroupFunction(name))
{
var pattern = Current;
if (!Match(TokenType.String))
{
throw new FormulaException(
$"Group function '{name}' expects a quoted regex pattern at position {pattern.Position}."
);
}
Expect(TokenType.RParen);
return FormulaGroups.Build(name, pattern.Text);
}
var args = new List<Node>();
if (!Peek(TokenType.RParen))
{
do
{
args.Add(ParseTernary());
} while (Match(TokenType.Comma));
}
Expect(TokenType.RParen);
return FormulaFunctions.Build(name, args);
}
private static Node Compare(Node left, Node right, Func<float, float, bool> op) =>
ctx => op(left(ctx), right(ctx)) ? True : False;
private Token Current => tokens[_pos];
private bool Peek(TokenType type) => Current.Type == type;
private bool Match(TokenType type)
{
if (Current.Type != type)
{
return false;
}
_pos++;
return true;
}
private void Expect(TokenType type)
{
if (!Match(type))
{
throw new FormulaException($"Expected {type} at position {Current.Position}.");
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace MrGameEng.Core;
/// <summary>
/// Engine time service: per-frame delta, total elapsed time, time scaling and frame counter.
/// Advanced once per frame by <see cref="GameHost"/>.
/// Advanced once per frame (or per fixed tick) by the host.
/// </summary>
public sealed class GameClock
{
+144
View File
@@ -0,0 +1,144 @@
namespace MrGameEng.Core;
/// <summary>
/// Discrete game-speed control layered over <see cref="GameClock.TimeScale"/>: a pause plus an
/// ordered list of speed multipliers (1×, 3×, 6× by default). Pausing remembers the current
/// running step so <see cref="Resume"/> restores it. <see cref="Changed"/> fires on every
/// transition so UI (speed buttons, indicators) can refresh. Deterministic and GPU-free;
/// registered as a service via <see cref="GameSpeedEngineExtensions.UseGameSpeed"/>.
/// </summary>
public sealed class GameSpeed
{
private readonly GameClock _clock;
private readonly float[] _steps;
private int _stepIndex;
private bool _paused;
/// <summary>
/// Creates a controller that writes <see cref="CurrentSpeed"/> to
/// <paramref name="clock"/>. <paramref name="steps"/> are the running multipliers in
/// ascending order; each must be positive. Empty defaults to 1×, 3×, 6×.
/// </summary>
public GameSpeed(GameClock clock, params float[] steps)
{
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_steps = steps is { Length: > 0 } ? (float[])steps.Clone() : [1f, 3f, 6f];
foreach (var step in _steps)
{
if (step <= 0f)
{
throw new ArgumentOutOfRangeException(
nameof(steps),
"Speed steps must be positive."
);
}
}
Apply();
}
/// <summary>The ordered running speeds (excludes the pause state).</summary>
public IReadOnlyList<float> Steps => _steps;
/// <summary>Index of the active running step within <see cref="Steps"/>.</summary>
public int StepIndex => _stepIndex;
/// <summary>True while gameplay is paused (clock time scale is 0).</summary>
public bool IsPaused => _paused;
/// <summary>Active multiplier: 0 while paused, otherwise <c>Steps[StepIndex]</c>.</summary>
public float CurrentSpeed => _paused ? 0f : _steps[_stepIndex];
/// <summary>Raised after any change to the pause state or the active step.</summary>
public event Action? Changed;
/// <summary>Pauses gameplay, remembering the current step for <see cref="Resume"/>.</summary>
public void Pause()
{
if (_paused)
{
return;
}
_paused = true;
Apply();
}
/// <summary>Resumes gameplay at the remembered step.</summary>
public void Resume()
{
if (!_paused)
{
return;
}
_paused = false;
Apply();
}
/// <summary>Toggles between paused and running.</summary>
public void TogglePause()
{
_paused = !_paused;
Apply();
}
/// <summary>Selects a running step by index (clamped to the valid range) and unpauses.</summary>
public void SetStep(int index)
{
_stepIndex = Math.Clamp(index, 0, _steps.Length - 1);
_paused = false;
Apply();
}
/// <summary>Steps to the next faster speed (clamped to the fastest) and unpauses.</summary>
public void Faster() => SetStep(_stepIndex + 1);
/// <summary>Steps to the next slower speed (clamped to the slowest) and unpauses.</summary>
public void Slower() => SetStep(_stepIndex - 1);
/// <summary>
/// Cycles through states: pause → slowest step → … → fastest step → pause. Handy for a
/// single "next speed" key or button.
/// </summary>
public void Cycle()
{
if (_paused)
{
_paused = false;
_stepIndex = 0;
}
else if (_stepIndex + 1 < _steps.Length)
{
_stepIndex++;
}
else
{
_paused = true;
}
Apply();
}
private void Apply()
{
_clock.TimeScale = CurrentSpeed;
Changed?.Invoke();
}
}
/// <summary>Wires the game-speed controller into the engine.</summary>
public static class GameSpeedEngineExtensions
{
/// <summary>
/// Creates a <see cref="GameSpeed"/> bound to the context's clock and registers it as a
/// service. Call once at startup. <paramref name="steps"/> are the running multipliers
/// (defaults to 1×, 3×, 6× when empty).
/// </summary>
public static GameSpeed UseGameSpeed(this EngineContext context, params float[] steps)
{
var speed = new GameSpeed(context.Clock, steps);
context.Services.Add(speed);
return speed;
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Diagnostics;
namespace MrGameEng.Core;
/// <summary>
/// A game-loop host without a window, GPU or any platform dependency: drives the active
/// scene's update phase on a fixed timestep. Suits dedicated servers, batch simulation and
/// tests. Scenes still own draw systems — they are simply never run, and platform services
/// (graphics device, window) are absent from <see cref="EngineContext.Services"/>, so only
/// platform-free modules can be used. The fixed step makes simulation time independent of
/// wall-clock jitter: N ticks always advance the world by exactly N ×
/// <see cref="FixedDeltaTime"/> seconds.
/// </summary>
public sealed class HeadlessHost : IDisposable
{
/// <summary>Engine context shared with scenes and systems.</summary>
public EngineContext Context { get; } = new();
/// <summary>Fixed simulation step in seconds: 1 / <see cref="HeadlessHostOptions.TicksPerSecond"/>.</summary>
public float FixedDeltaTime { get; }
/// <summary>Ticks completed since the host was created.</summary>
public long TickCount => Context.Clock.FrameCount;
// Отстав сильнее этого, Run ресинкается с настоящим временем вместо лавины тиков.
private const double MaxLagSeconds = 1.0;
private readonly HeadlessHostOptions _options;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>. The scene
/// loads on the first tick, mirroring the windowed host's deferred switch.</summary>
public HeadlessHost(HeadlessHostOptions options, Scene initialScene)
{
if (options.TicksPerSecond <= 0f)
{
throw new ArgumentOutOfRangeException(
nameof(options),
"TicksPerSecond must be positive."
);
}
_options = options;
FixedDeltaTime = 1f / options.TicksPerSecond;
Context.Scenes.Switch(initialScene);
}
/// <summary>Advances the world by exactly one fixed tick.</summary>
public void Tick()
{
Context.Clock.Advance(FixedDeltaTime);
Context.Scenes.Update(Context.Clock);
}
/// <summary>Advances the world by <paramref name="count"/> ticks as fast as possible.</summary>
public void RunTicks(long count)
{
for (long i = 0; i < count; i++)
{
Tick();
}
}
/// <summary>
/// Runs until <paramref name="cancellationToken"/> is cancelled. With
/// <see cref="HeadlessHostOptions.Realtime"/> ticks are paced to the wall clock — the
/// loop sleeps when ahead and, having fallen more than a second behind, resyncs instead
/// of bursting a catch-up avalanche. Pacing relies on <see cref="Thread.Sleep(TimeSpan)"/>
/// and is accurate to a few milliseconds, not exact.
/// </summary>
public void Run(CancellationToken cancellationToken = default)
{
if (!_options.Realtime)
{
while (!cancellationToken.IsCancellationRequested)
{
Tick();
}
return;
}
var wallClock = Stopwatch.StartNew();
var nextTickAt = 0.0;
while (!cancellationToken.IsCancellationRequested)
{
Tick();
nextTickAt += FixedDeltaTime;
var ahead = nextTickAt - wallClock.Elapsed.TotalSeconds;
if (ahead > 0)
{
Thread.Sleep(TimeSpan.FromSeconds(ahead));
}
else if (-ahead > MaxLagSeconds)
{
nextTickAt = wallClock.Elapsed.TotalSeconds;
}
}
}
/// <summary>Unloads the active scene and disposes context-owned services.</summary>
public void Dispose()
{
Context.Scenes.Switch(null);
Context.Scenes.ApplyPending();
Context.DisposeOwnedResources();
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace MrGameEng.Core;
/// <summary>Loop settings for <see cref="HeadlessHost"/>.</summary>
public sealed class HeadlessHostOptions
{
/// <summary>Fixed simulation rate in ticks per second. Must be positive.</summary>
public float TicksPerSecond { get; set; } = 60f;
/// <summary>
/// When true, <see cref="HeadlessHost.Run"/> paces ticks to the wall clock (a dedicated
/// server); when false it runs flat out (batch simulation). <see cref="HeadlessHost.RunTicks"/>
/// always runs flat out regardless of this setting.
/// </summary>
public bool Realtime { get; set; } = true;
}
+13
View File
@@ -0,0 +1,13 @@
namespace MrGameEng.Core;
/// <summary>
/// Shared flag service: true while a UI overlay (the developer console, a modal dialog)
/// captures input. Producers (e.g. the DevConsole module) set <see cref="Captured"/> while
/// open; consumers (the Input module, scene UI rendering) suppress game-facing input while
/// it is set. Lives in Core so modules can cooperate without referencing each other.
/// </summary>
public sealed class InputCapture
{
/// <summary>True while game-facing input should be suppressed.</summary>
public bool Captured { get; set; }
}
+4 -2
View File
@@ -1,12 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
<InternalsVisibleTo Include="MrGameEng.Host" />
</ItemGroup>
</Project>
+30 -1
View File
@@ -7,6 +7,9 @@ namespace MrGameEng.Core;
/// A scene owns its ECS world (<see cref="EntityStore"/>) and two system roots:
/// <see cref="UpdateSystems"/> for game logic and <see cref="DrawSystems"/> for rendering.
/// Override <see cref="OnLoad"/> to create entities and register systems.
/// Scene instances are single-use: <see cref="OnLoad"/> populates the store and the system
/// roots, and nothing resets them on unload — switch to a <b>new</b> instance instead of
/// reloading an old one (re-loading throws).
/// </summary>
public abstract class Scene
{
@@ -20,12 +23,15 @@ public abstract class Scene
public SystemRoot DrawSystems { get; }
/// <summary>Engine context. Valid from <see cref="OnLoad"/> until <see cref="OnUnload"/>.</summary>
public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded.");
public EngineContext Context =>
_context ?? throw new InvalidOperationException("Scene is not loaded.");
/// <summary>True while the scene is the active, loaded scene.</summary>
public bool IsLoaded => _context is not null;
private EngineContext? _context;
private bool _loadedOnce;
private readonly List<Action> _unloadActions = [];
/// <summary>Initializes the scene's ECS world and system roots.</summary>
protected Scene()
@@ -40,6 +46,12 @@ public abstract class Scene
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
protected virtual void OnUnload() { }
/// <summary>
/// Registers a callback run once when the scene unloads (after <see cref="OnUnload"/>),
/// in reverse registration order. Engine modules use this to release per-scene resources.
/// </summary>
public void RegisterUnload(Action action) => _unloadActions.Add(action);
/// <summary>Runs the update phase. Called by <see cref="SceneManager"/>.</summary>
public virtual void Update(GameClock clock) =>
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
@@ -50,6 +62,17 @@ public abstract class Scene
internal void Load(EngineContext context)
{
// Без guard'а повторная загрузка молча дублирует системы и сущности
// (OnLoad добавляет в те же SystemRoot/EntityStore поверх старого содержимого).
if (_loadedOnce)
{
throw new InvalidOperationException(
$"Scene '{GetType().Name}' was already loaded once. Scene instances are "
+ "single-use: create a new instance instead of switching back to an old one."
);
}
_loadedOnce = true;
_context = context;
OnLoad();
}
@@ -57,6 +80,12 @@ public abstract class Scene
internal void Unload()
{
OnUnload();
for (var i = _unloadActions.Count - 1; i >= 0; i--)
{
_unloadActions[i]();
}
_unloadActions.Clear();
_context = null;
}
}
+42 -21
View File
@@ -22,13 +22,24 @@ public sealed class SceneManager
/// <summary>True while a transition is covering or revealing.</summary>
public bool IsTransitioning => _state != State.Idle;
/// <summary>The transition currently covering or revealing, or null while idle.
/// Hosts that render overlays read this together with <see cref="TransitionCoverage"/>
/// and <see cref="TransitionPhase"/> during their draw phase.</summary>
public Transition? ActiveTransition => _state == State.Idle ? null : _transition;
/// <summary>Coverage of the active transition: 0 = scene fully visible, 1 = fully covered.</summary>
public float TransitionCoverage => Math.Clamp(_coverage, 0f, 1f);
/// <summary>Phase of the active transition. Meaningful only while <see cref="IsTransitioning"/>.</summary>
public TransitionPhase TransitionPhase =>
_state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
private readonly EngineContext _context;
private Scene? _pending;
private bool _hasPending;
private Transition? _transition;
private State _state;
private float _coverage;
private TransitionRenderer? _renderer;
internal SceneManager(EngineContext context) => _context = context;
@@ -36,18 +47,24 @@ public sealed class SceneManager
/// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
/// the start of the next update tick; with one, the old scene is first covered.
/// Passing null unloads the current scene. Calling during an active transition replaces
/// the pending target scene.
/// the pending target scene; a switch requested while a transition is revealing starts
/// covering again from the current coverage.
/// </summary>
public void Switch(Scene? scene, Transition? transition = null)
{
_pending = scene;
_hasPending = true;
if (_state == State.Idle && transition is not null)
if (transition is not null && _state != State.CoveringOut)
{
_transition = transition;
if (_state == State.Idle)
{
_coverage = 0f;
}
// Из RevealingIn закрытие продолжается с текущего coverage — без скачка.
_state = State.CoveringOut;
_coverage = 0f;
}
}
@@ -61,7 +78,12 @@ public sealed class SceneManager
break;
case State.CoveringOut:
_coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime);
_coverage = Advance(
_coverage,
+1f,
_transition!.OutDuration,
clock.UnscaledDeltaTime
);
if (_coverage >= 1f)
{
ApplyPending();
@@ -71,7 +93,12 @@ public sealed class SceneManager
break;
case State.RevealingIn:
_coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime);
_coverage = Advance(
_coverage,
-1f,
_transition!.InDuration,
clock.UnscaledDeltaTime
);
if (_coverage <= 0f)
{
_state = State.Idle;
@@ -84,20 +111,9 @@ public sealed class SceneManager
Current?.Update(clock);
}
/// <summary>Draws the active scene and the transition overlay on top. Called by the host.</summary>
public void Draw(GameClock clock)
{
Current?.Draw(clock);
if (_state == State.Idle || !_context.HasGraphicsDevice)
{
return;
}
_renderer ??= new TransitionRenderer(_context.GraphicsDevice);
var phase = _state == State.CoveringOut ? TransitionPhase.Out : TransitionPhase.In;
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
/// <summary>Draws the active scene. Transition overlays are rendered by the host on top,
/// from <see cref="ActiveTransition"/> and <see cref="TransitionCoverage"/>.</summary>
public void Draw(GameClock clock) => Current?.Draw(clock);
internal void ApplyPending()
{
@@ -114,7 +130,12 @@ public sealed class SceneManager
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
}
private static float Advance(float coverage, float direction, float duration, float deltaTime) =>
private static float Advance(
float coverage,
float direction,
float duration,
float deltaTime
) =>
duration <= 0f
? coverage + direction
: Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f);
+36 -5
View File
@@ -9,25 +9,56 @@ public sealed class ServiceRegistry
private readonly Dictionary<Type, object> _services = new();
/// <summary>Registers a service instance under type <typeparamref name="T"/>. Throws if already registered.</summary>
public void Add<T>(T service) where T : class
public void Add<T>(T service)
where T : class
{
if (!_services.TryAdd(typeof(T), service))
{
throw new InvalidOperationException($"Service of type {typeof(T)} is already registered.");
throw new InvalidOperationException(
$"Service of type {typeof(T)} is already registered."
);
}
}
/// <summary>Returns the registered service of type <typeparamref name="T"/>. Throws if missing.</summary>
public T Get<T>() where T : class
public T Get<T>()
where T : class
{
return _services.TryGetValue(typeof(T), out var service)
? (T)service
: throw new InvalidOperationException($"Service of type {typeof(T)} is not registered.");
: throw new InvalidOperationException(
$"Service of type {typeof(T)} is not registered."
);
}
/// <summary>Returns the registered service of type <typeparamref name="T"/> or null.</summary>
public T? GetOrDefault<T>() where T : class
public T? GetOrDefault<T>()
where T : class
{
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
}
/// <summary>
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
/// when registered under several types) and clears the registry. Instances in
/// <paramref name="except"/> are skipped. Called on host shutdown.
/// </summary>
internal void DisposeServices(params object[] except)
{
var skipped = new HashSet<object>(except, ReferenceEqualityComparer.Instance);
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var service in _services.Values)
{
if (
!skipped.Contains(service)
&& service is IDisposable disposable
&& disposed.Add(service)
)
{
disposable.Dispose();
}
}
_services.Clear();
}
}
+6 -44
View File
@@ -1,5 +1,3 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
/// <summary>Phase of a scene transition.</summary>
@@ -13,10 +11,12 @@ public enum TransitionPhase
}
/// <summary>
/// Visual transition between scenes. The scene switch itself happens at full coverage,
/// so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Transitions are stateless and reusable; progress is tracked by <see cref="SceneManager"/>.
/// Runs on unscaled time, so it works while gameplay is paused.
/// Timing of a visual transition between scenes. The scene switch itself happens at full
/// coverage, so a slow <c>OnLoad</c> of the next scene is hidden behind the overlay.
/// Progress is tracked by <see cref="SceneManager"/> on unscaled time, so transitions work
/// while gameplay is paused. The core only times the phases — how the overlay looks is
/// defined by the host (the MonoGame host's <c>OverlayTransition</c> and the
/// <c>Transitions</c> factories); headless hosts simply let transitions pass invisibly.
/// </summary>
public abstract class Transition
{
@@ -32,42 +32,4 @@ public abstract class Transition
OutDuration = Math.Max(0f, outDuration);
InDuration = Math.Max(0f, inDuration);
}
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) =>
renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
}
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Myra" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+48 -11
View File
@@ -33,6 +33,15 @@ public readonly struct CameraState
/// <summary>Physical-screen to virtual-pixel mapping.</summary>
public required ViewportMapping Mapping { get; init; }
/// <summary>
/// World point at the centre of the virtual screen — the camera's <em>effective</em> position
/// after bounds-clamping, i.e. what the view is actually built around. Prefer this over the raw
/// <see cref="Camera.Position"/> when anchoring zoom-to-cursor, so the reposition matches what is
/// rendered even while the camera is clamped against <see cref="Camera.Bounds"/>.
/// </summary>
public Vector2 WorldCenter =>
Vector2.Transform(new Vector2(VirtualWidth / 2f, VirtualHeight / 2f), InverseView);
/// <summary>Converts a physical screen point to world coordinates.</summary>
public Vector2 ScreenToWorld(Vector2 screen)
{
@@ -52,23 +61,35 @@ public readonly struct CameraState
public static class CameraMath
{
/// <summary>Computes the full camera state for a frame.</summary>
public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping)
public static CameraState Compute(
in Camera camera,
int virtualWidth,
int virtualHeight,
ViewportMapping mapping
)
{
var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom;
var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom);
var view =
Matrix.CreateTranslation(-position.X, -position.Y, 0f) *
Matrix.CreateRotationZ(-camera.Rotation) *
Matrix.CreateScale(zoom, zoom, 1f) *
Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
Matrix.CreateTranslation(-position.X, -position.Y, 0f)
* Matrix.CreateRotationZ(-camera.Rotation)
* Matrix.CreateScale(zoom, zoom, 1f)
* Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
var inverseView = Matrix.Invert(view);
return new CameraState
{
View = view,
Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f),
Projection = Matrix.CreateOrthographicOffCenter(
0f,
virtualWidth,
virtualHeight,
0f,
0f,
1f
),
InverseView = inverseView,
CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight),
VirtualWidth = virtualWidth,
@@ -81,14 +102,29 @@ public static class CameraMath
/// Computes the letterbox mapping that fits the virtual resolution into a physical
/// viewport, preserving aspect ratio and centering.
/// </summary>
public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight)
public static ViewportMapping ComputeMapping(
int screenWidth,
int screenHeight,
int virtualWidth,
int virtualHeight
)
{
var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight);
var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f;
var scale = MathF.Min(
(float)screenWidth / virtualWidth,
(float)screenHeight / virtualHeight
);
var offset =
new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale)
/ 2f;
return new ViewportMapping(offset, scale);
}
private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom)
private static Vector2 ClampToBounds(
in Camera camera,
int virtualWidth,
int virtualHeight,
float zoom
)
{
if (camera.Bounds is not { } bounds)
{
@@ -100,7 +136,8 @@ public static class CameraMath
var halfH = virtualHeight / (2f * zoom);
return new Vector2(
ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW),
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH));
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH)
);
}
private static float ClampAxis(float value, float min, float max) =>
+24 -8
View File
@@ -10,7 +10,11 @@ public static class CullingMath
/// (valid for any rotation), given its transform, region size in pixels and origin.
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin)
in Transform2D transform,
float regionWidth,
float regionHeight,
Vector2 origin
)
{
var scaledW = regionWidth * transform.Scale.X;
var scaledH = regionHeight * transform.Scale.Y;
@@ -24,20 +28,33 @@ public static class CullingMath
/// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform).
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, Texture2DRegion region, Vector2 origin)
in Transform2D transform,
Texture2DRegion region,
Vector2 origin
)
{
var center = SpriteCenter(
in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin);
in transform,
region.Width * transform.Scale.X,
region.Height * transform.Scale.Y,
origin
);
var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y));
return (center, 0.5f * region.Diagonal * maxScale);
}
private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin)
private static Vector2 SpriteCenter(
in Transform2D transform,
float scaledW,
float scaledH,
Vector2 origin
)
{
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
var toCenter = new Vector2(
scaledW / 2f - origin.X * transform.Scale.X,
scaledH / 2f - origin.Y * transform.Scale.Y);
scaledH / 2f - origin.Y * transform.Scale.Y
);
if (transform.Rotation == 0f)
{
@@ -45,9 +62,8 @@ public static class CullingMath
}
var (sin, cos) = MathF.SinCos(transform.Rotation);
return transform.Position + new Vector2(
toCenter.X * cos - toCenter.Y * sin,
toCenter.X * sin + toCenter.Y * cos);
return transform.Position
+ new Vector2(toCenter.X * cos - toCenter.Y * sin, toCenter.X * sin + toCenter.Y * cos);
}
/// <summary>True when the circle overlaps the rectangle.</summary>
@@ -0,0 +1,19 @@
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Graphics;
/// <summary>Graphics-side accessors for the platform-free <see cref="EngineContext"/>.</summary>
public static class EngineContextGraphicsExtensions
{
/// <summary>
/// Returns the <see cref="GraphicsDevice"/> service published by a windowed host.
/// Throws in a headless context, where no graphics device exists.
/// </summary>
public static GraphicsDevice GetGraphicsDevice(this EngineContext context) =>
context.Services.GetOrDefault<GraphicsDevice>()
?? throw new InvalidOperationException(
"GraphicsDevice is not available: no windowed host has published it "
+ "(headless context, or graphics are not initialized yet)."
);
}
+45 -13
View File
@@ -23,7 +23,11 @@ public enum LayerSortMode
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
Depth,
/// <summary>Order by world Y position (top-down games: lower on screen = drawn in front).</summary>
/// <summary>
/// Order by the entity's world Y position (<see cref="Transform2D.Position"/>):
/// top-down games, lower on screen = drawn in front. Place sprite origins at the
/// feet/base so the sort point matches the visual anchor.
/// </summary>
YSort,
}
@@ -32,31 +36,59 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye
/// <summary>
/// Registry of render layers. Layers are registered up front (typically when the renderer is
/// created) and drawn in registration order. Maximum 256 layers.
/// created) and drawn in registration order. Maximum 256 layers. Reads are lock-free and
/// thread-safe (the renderer reads layers from parallel submit workers); registration swaps
/// an immutable snapshot, so registering mid-frame never tears a concurrent read.
/// </summary>
public sealed class LayerRegistry
{
private readonly List<RenderLayer> _layers = [];
private readonly object _sync = new();
private volatile RenderLayer[] _layers = [];
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
public LayerRegistry() => Register("Default");
/// <summary>Number of registered layers.</summary>
public int Count => _layers.Count;
public int Count => _layers.Length;
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
public LayerId Register(
string name,
LayerSpace space = LayerSpace.World,
LayerSortMode sortMode = LayerSortMode.Depth
)
{
if (_layers.Count == 256)
lock (_sync)
{
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
}
var layers = _layers;
if (layers.Length == 256)
{
throw new InvalidOperationException(
"Maximum number of render layers (256) reached."
);
}
var id = new LayerId((byte)_layers.Count);
_layers.Add(new RenderLayer(id, name, space, sortMode));
return id;
var id = new LayerId((byte)layers.Length);
var grown = new RenderLayer[layers.Length + 1];
Array.Copy(layers, grown, layers.Length);
grown[layers.Length] = new RenderLayer(id, name, space, sortMode);
_layers = grown;
return id;
}
}
/// <summary>Returns the layer with the given id.</summary>
public RenderLayer this[LayerId id] => _layers[id.Value];
/// <summary>Returns the layer with the given id; throws when the id was never registered.</summary>
public RenderLayer this[LayerId id]
{
get
{
var layers = _layers;
return id.Value < layers.Length
? layers[id.Value]
: throw new ArgumentOutOfRangeException(
nameof(id),
$"Render layer {id.Value} is not registered (registered: {layers.Length})."
);
}
}
}
@@ -0,0 +1,86 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
namespace MrGameEng.Lighting;
/// <summary>Colors for the day/night ambient: the tint at deep night and at midday.</summary>
public readonly record struct DayNightSettings
{
/// <summary>Ambient tint at midnight (a dim, cool night).</summary>
public Color NightColor { get; init; }
/// <summary>Ambient tint at noon (white = no tint).</summary>
public Color DayColor { get; init; }
/// <summary>Sensible defaults: a dim blue night, untinted day.</summary>
public static DayNightSettings Default =>
new() { NightColor = new Color(45, 55, 95), DayColor = Color.White };
}
/// <summary>
/// Day/night ambient driven by <see cref="Calendar.DayProgress"/>: a daylight factor (0 at midnight,
/// 1 at noon) and an ambient color lerped from night to day. <see cref="SampleAt"/> is the sampleable
/// light interface read by both the renderer (scene darkening) and the simulation (plant light);
/// it returns the global daylight today and will return locally-shadowed light once point lights and
/// occlusion land. Pure read-side, GPU-free and deterministic.
/// </summary>
public sealed class DayNight
{
private readonly Calendar _calendar;
private readonly DayNightSettings _settings;
/// <summary>Creates the cycle reading <paramref name="calendar"/> with the given <paramref name="settings"/>.</summary>
public DayNight(Calendar calendar, DayNightSettings settings)
{
_calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
_settings = settings;
}
/// <summary>Daylight factor in <c>[0, 1]</c> — 0 at midnight, 1 at noon, 0 again at midnight.</summary>
public float Daylight
{
get
{
var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress);
return value > 0f ? value : 0f;
}
}
/// <summary>Global light intensity in <c>[0, 1]</c>; same as <see cref="Daylight"/> today.</summary>
public float Intensity => Daylight;
/// <summary>Light intensity at a world point in <c>[0, 1]</c>. Global today; local (with shadows) later.</summary>
public float SampleAt(Vector2 world) => Daylight;
/// <summary>
/// Offset, in grid cells, of the shadow an occluder casts under the current sun: opposite the
/// sun's eastwest position and longest near sunrise/sunset (low sun), shrinking to zero at noon
/// and at night. Feeds the lightmap's directional shadow pass; <paramref name="maxLength"/> caps
/// the dawn/dusk shadow length. Tilted slightly "south" (down) so shadows fall in front of objects.
/// </summary>
public Vector2 SunShadow(float maxLength)
{
var day = (_calendar.DayProgress - 0.25f) / 0.5f; // daytime fraction over [06:00, 18:00]
if (day <= 0f || day >= 1f)
{
return Vector2.Zero; // night — no sun; the ambient floor handles darkness
}
var altitude = MathF.Sin(day * MathF.PI); // 0 at dawn/dusk, 1 at noon
var direction = new Vector2(2f * day - 1f, 0.4f); // sun east→west ⇒ shadow west→east, tilted south
direction.Normalize();
return direction * (maxLength * (1f - altitude));
}
/// <summary>Ambient tint for the scene: night color at night, day color at noon, eased between.</summary>
public Color Ambient
{
get
{
var t = Smoothstep(Daylight);
return Color.Lerp(_settings.NightColor, _settings.DayColor, t);
}
}
private static float Smoothstep(float x) => x * x * (3f - 2f * x);
}
@@ -0,0 +1,49 @@
using Friflo.Engine.ECS.Systems;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Update-phase system that pushes the current <see cref="DayNight.Ambient"/> into the renderer's
/// <see cref="Renderer2D.AmbientLight"/> each frame, so the world darkens toward night and brightens
/// toward noon. Runs in the update phase, before the draw phase consumes the value.
/// </summary>
public sealed class DayNightSystem : BaseSystem
{
private readonly DayNight _dayNight;
private readonly Renderer2D _renderer;
internal DayNightSystem(DayNight dayNight, Renderer2D renderer)
{
_dayNight = dayNight;
_renderer = renderer;
}
/// <inheritdoc />
protected override void OnUpdateGroup() => _renderer.AmbientLight = _dayNight.Ambient;
}
/// <summary>Wires the day/night ambient cycle into a <see cref="Scene"/>.</summary>
public static class SceneLightingExtensions
{
/// <summary>
/// Creates a <see cref="DayNight"/> bound to the scene's <see cref="Calendar"/> service, registers
/// it, and drives <paramref name="renderer"/>'s ambient light from it. Call from <c>OnLoad</c>
/// after <see cref="CalendarEngineExtensions.UseCalendar"/> and <c>UseRenderer2D</c>.
/// </summary>
public static DayNight UseDayNight(
this Scene scene,
Renderer2D renderer,
DayNightSettings? settings = null
)
{
var dayNight = new DayNight(
scene.Context.Services.Get<Calendar>(),
settings ?? DayNightSettings.Default
);
scene.Context.Services.Add(dayNight);
scene.UpdateSystems.Add(new DayNightSystem(dayNight, renderer));
return dayNight;
}
}
@@ -0,0 +1,60 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Scene lighting service: owns the <see cref="Lightmap"/> and exposes a sampleable local light for
/// the simulation. Built by <see cref="SceneLightmapExtensions.UseLighting"/>, which also registers
/// the systems that rebuild the lightmap and multiply it over the scene.
/// </summary>
public sealed class Lighting
{
/// <summary>The light grid rendered over the world and sampled by the simulation.</summary>
public Lightmap Lightmap { get; }
/// <summary>Creates the service over <paramref name="lightmap"/>.</summary>
public Lighting(Lightmap lightmap) => Lightmap = lightmap;
/// <summary>Local light in <c>[0, 1]</c> at a world point (e.g. for plant growth under canopy).</summary>
public float SampleAt(Vector2 world) => Lightmap.SampleAt(world);
}
/// <summary>Wires the 2D lightmap (day/night × occlusion + point lights) into a <see cref="Scene"/>.</summary>
public static class SceneLightmapExtensions
{
/// <summary>
/// Builds a <see cref="Lighting"/> service for a <paramref name="width"/>×<paramref name="height"/>
/// cell world and registers the rebuild and composite systems. Ambient comes from
/// <paramref name="dayNight"/>; <paramref name="occluders"/> supplies the current occluder grid
/// (row-major, length width×height) each rebuild. Call after <c>UseRenderer2D</c>/<c>UseTilemaps</c>
/// and before <c>UseUI</c> so the lightmap composites over the world but under the HUD.
/// </summary>
public static Lighting UseLighting(
this Scene scene,
Renderer2D renderer,
DayNight dayNight,
int width,
int height,
float cellSize,
Vector2 origin,
Func<bool[]> occluders
)
{
var device = scene.Context.GetGraphicsDevice();
var lightmap = new Lightmap(device, width, height, cellSize, origin);
var lighting = new Lighting(lightmap);
scene.Context.Services.Add(lighting);
RegisterUnloadDispose(scene, lightmap);
scene.UpdateSystems.Add(
new LightmapSystem(scene.Store, lightmap, dayNight, occluders, cellSize, origin)
);
scene.DrawSystems.Add(new LightmapRenderSystem(device, renderer, lightmap));
return lighting;
}
private static void RegisterUnloadDispose(Scene scene, Lightmap lightmap) =>
scene.RegisterUnload(lightmap.Dispose);
}
@@ -0,0 +1,85 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// A per-cell light grid backed by a greyscale <see cref="Texture2D"/>. <see cref="LightmapBuilder"/>
/// fills <see cref="Light"/>; <see cref="Upload"/> pushes it to the texture (the renderer multiplies
/// it over the world for soft, cell-resolution shadows); <see cref="SampleAt"/> reads bilinearly for
/// the simulation (local light at a plant). The grid maps cell (x,y) to world
/// <c>Origin + (x+0.5, y+0.5)·CellSize</c>.
/// </summary>
public sealed class Lightmap : IDisposable
{
/// <summary>Grid width in cells.</summary>
public int Width { get; }
/// <summary>Grid height in cells.</summary>
public int Height { get; }
/// <summary>World size of one cell.</summary>
public float CellSize { get; }
/// <summary>World position of cell (0,0)'s top-left.</summary>
public Vector2 Origin { get; }
/// <summary>Per-cell light in <c>[0, 1]</c>, row-major. Filled by <see cref="LightmapBuilder"/>.</summary>
public float[] Light { get; }
/// <summary>The greyscale light texture (one texel per cell), updated by <see cref="Upload"/>.</summary>
public Texture2D Texture { get; }
private readonly Color[] _pixels;
/// <summary>Creates a lightmap grid and its backing texture on <paramref name="device"/>.</summary>
public Lightmap(GraphicsDevice device, int width, int height, float cellSize, Vector2 origin)
{
Width = width;
Height = height;
CellSize = cellSize;
Origin = origin;
Light = new float[width * height];
_pixels = new Color[width * height];
Texture = new Texture2D(device, width, height);
Array.Fill(Light, 1f);
Upload();
}
/// <summary>Writes the current <see cref="Light"/> grid into the texture as greyscale.</summary>
public void Upload()
{
for (var i = 0; i < Light.Length; i++)
{
var b = (byte)(Math.Clamp(Light[i], 0f, 1f) * 255f);
_pixels[i] = new Color(b, b, b, (byte)255);
}
Texture.SetData(_pixels);
}
/// <summary>Bilinearly samples the light at a world point; clamps at the edges.</summary>
public float SampleAt(Vector2 world)
{
var fx = (world.X - Origin.X) / CellSize - 0.5f;
var fy = (world.Y - Origin.Y) / CellSize - 0.5f;
fx = Math.Clamp(fx, 0f, Width - 1f);
fy = Math.Clamp(fy, 0f, Height - 1f);
var x0 = (int)fx;
var y0 = (int)fy;
var x1 = Math.Min(x0 + 1, Width - 1);
var y1 = Math.Min(y0 + 1, Height - 1);
var tx = fx - x0;
var ty = fy - y0;
var top = Lerp(Light[y0 * Width + x0], Light[y0 * Width + x1], tx);
var bottom = Lerp(Light[y1 * Width + x0], Light[y1 * Width + x1], tx);
return Lerp(top, bottom, ty);
}
private static float Lerp(float a, float b, float t) => a + (b - a) * t;
/// <inheritdoc />
public void Dispose() => Texture.Dispose();
}
@@ -0,0 +1,184 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>One point light projected onto the light grid: a cell position, a radius in cells and an intensity.</summary>
public readonly record struct LightSample(int X, int Y, float Radius, float Intensity);
/// <summary>
/// Builds a per-cell light grid (CPU, GPU-free, testable): an ambient base dimmed under occluders,
/// plus point lights that attenuate with distance and are blocked by occluders between the source
/// and the cell (grid-traced shadows). Pure data — a <see cref="Lightmap"/> turns the grid into a
/// texture and the simulation samples it for local light.
/// </summary>
public static class LightmapBuilder
{
/// <summary>How much of the ambient light reaches a cell that is itself an occluder (canopy/shade).</summary>
public const float OccluderShade = 0.35f;
/// <summary>
/// Fills <paramref name="light"/> (length <paramref name="width"/>×<paramref name="height"/>) with
/// ambient light, shading occluder cells, casts each occluder's directional sun shadow along
/// <paramref name="sunShadow"/> (cells), then adds each point light with grid-traced occlusion.
/// Values end clamped to <c>[0, 1]</c>. A zero <paramref name="sunShadow"/> or
/// <paramref name="sunShadowStrength"/> skips the directional pass (e.g. at night/noon).
/// </summary>
public static void Build(
float[] light,
int width,
int height,
float ambient,
ReadOnlySpan<bool> occluders,
IReadOnlyList<LightSample> lights,
Vector2 sunShadow = default,
float sunShadowStrength = 0f
)
{
for (var i = 0; i < light.Length; i++)
{
light[i] = occluders[i] ? ambient * OccluderShade : ambient;
}
CastSunShadows(light, width, height, occluders, sunShadow, sunShadowStrength);
foreach (var l in lights)
{
if (l.Radius <= 0f || l.Intensity <= 0f)
{
continue;
}
var r = (int)MathF.Ceiling(l.Radius);
var minX = Math.Max(0, l.X - r);
var maxX = Math.Min(width - 1, l.X + r);
var minY = Math.Max(0, l.Y - r);
var maxY = Math.Min(height - 1, l.Y + r);
for (var y = minY; y <= maxY; y++)
{
for (var x = minX; x <= maxX; x++)
{
var dx = x - l.X;
var dy = y - l.Y;
var d = MathF.Sqrt(dx * dx + dy * dy);
if (d > l.Radius)
{
continue;
}
if (!Visible(l.X, l.Y, x, y, occluders, width))
{
continue; // в тени за препятствием
}
light[y * width + x] += l.Intensity * (1f - d / l.Radius);
}
}
}
for (var i = 0; i < light.Length; i++)
{
light[i] = Math.Clamp(light[i], 0f, 1f);
}
}
// Направленная тень от солнца: каждый окклюдер (гора/зрелая крона) отбрасывает тень вдоль
// вектора sunShadow (в клетках). Затемнение гуще у основания и тает к концу тени; сами клетки-
// окклюдеры не трогаем (они уже затенены). Окклюдеры разрежены, так что проход дёшев.
private static void CastSunShadows(
float[] light,
int width,
int height,
ReadOnlySpan<bool> occluders,
Vector2 sunShadow,
float strength
)
{
if (strength <= 0f)
{
return;
}
var steps = (int)MathF.Ceiling(sunShadow.Length());
if (steps <= 0)
{
return;
}
var stepX = sunShadow.X / steps;
var stepY = sunShadow.Y / steps;
for (var oy = 0; oy < height; oy++)
{
for (var ox = 0; ox < width; ox++)
{
if (!occluders[oy * width + ox])
{
continue;
}
for (var s = 1; s <= steps; s++)
{
var cx = ox + (int)MathF.Round(stepX * s);
var cy = oy + (int)MathF.Round(stepY * s);
if (cx < 0 || cx >= width || cy < 0 || cy >= height)
{
break;
}
var index = cy * width + cx;
if (occluders[index])
{
continue; // тень проходит над другими окклюдерами — они и так тёмные
}
var falloff = 1f - (float)(s - 1) / steps; // гуще у основания тени
light[index] *= 1f - strength * falloff;
}
}
}
}
// Есть ли прямая видимость между клетками: проводим линию (Брезенхем) и проверяем
// промежуточные клетки на окклюдер (концы исключены).
private static bool Visible(
int x0,
int y0,
int x1,
int y1,
ReadOnlySpan<bool> occluders,
int width
)
{
var dx = Math.Abs(x1 - x0);
var dy = Math.Abs(y1 - y0);
var sx = x0 < x1 ? 1 : -1;
var sy = y0 < y1 ? 1 : -1;
var err = dx - dy;
var x = x0;
var y = y0;
while (true)
{
if (x == x1 && y == y1)
{
return true;
}
if ((x != x0 || y != y0) && occluders[y * width + x])
{
return false;
}
var e2 = 2 * err;
if (e2 > -dy)
{
err -= dy;
x += sx;
}
if (e2 < dx)
{
err += dx;
y += sy;
}
}
}
}
@@ -0,0 +1,168 @@
using Friflo.Engine.ECS;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace MrGameEng.Lighting;
/// <summary>
/// Rebuilds the lightmap a few times a second: ambient from the day/night cycle, occluder cells from
/// the game-supplied grid and point lights from the ECS, then uploads it to the texture. Throttled by
/// frame skipping — the light changes slowly, so 10 Hz looks smooth and keeps cost low.
/// </summary>
public sealed class LightmapSystem : BaseSystem
{
private const int RebuildEvery = 6; // ~10 Гц при 60 fps
private const float NightFloor = 0.24f; // ночь тусклая, но не чёрная (лунный свет) — чуть светлее, чем было
private const float MaxShadowCells = 7f; // макс. длина тени от солнца (на рассвете/закате)
private const float SunShadowStrength = 0.5f; // насколько темнеет клетка у основания тени
private readonly Lightmap _lightmap;
private readonly DayNight _dayNight;
private readonly Func<bool[]> _occluders;
private readonly float _cellSize;
private readonly Vector2 _origin;
private readonly ArchetypeQuery<Transform2D, PointLight> _query;
private readonly List<LightSample> _lights = [];
private int _frame;
internal LightmapSystem(
EntityStore store,
Lightmap lightmap,
DayNight dayNight,
Func<bool[]> occluders,
float cellSize,
Vector2 origin
)
{
_lightmap = lightmap;
_dayNight = dayNight;
_occluders = occluders;
_cellSize = cellSize;
_origin = origin;
_query = store.Query<Transform2D, PointLight>();
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
if (++_frame < RebuildEvery)
{
return;
}
_frame = 0;
_lights.Clear();
foreach (var (transforms, lights, _) in _query.Chunks)
{
var t = transforms.Span;
var l = lights.Span;
for (var i = 0; i < t.Length; i++)
{
if (l[i].Radius <= 0f || l[i].Intensity <= 0f)
{
continue;
}
var cx = (int)((t[i].Position.X - _origin.X) / _cellSize);
var cy = (int)((t[i].Position.Y - _origin.Y) / _cellSize);
_lights.Add(new LightSample(cx, cy, l[i].Radius / _cellSize, l[i].Intensity));
}
}
var ambient = NightFloor + (1f - NightFloor) * _dayNight.Intensity;
LightmapBuilder.Build(
_lightmap.Light,
_lightmap.Width,
_lightmap.Height,
ambient,
_occluders(),
_lights,
_dayNight.SunShadow(MaxShadowCells),
SunShadowStrength
);
_lightmap.Upload();
}
}
/// <summary>
/// Draws the lightmap over the world as a single texture quad with multiply blending and linear
/// filtering — the world darkens by the grid and shadows read soft. Registered after the sprite flush
/// (so it lands on the drawn scene) and before the screen-space UI (so the HUD stays at full brightness).
/// </summary>
public sealed class LightmapRenderSystem : BaseSystem
{
private static readonly BlendState Multiply = new()
{
ColorSourceBlend = Blend.DestinationColor,
ColorDestinationBlend = Blend.Zero,
AlphaSourceBlend = Blend.DestinationAlpha,
AlphaDestinationBlend = Blend.Zero,
};
private readonly GraphicsDevice _device;
private readonly Renderer2D _renderer;
private readonly Lightmap _lightmap;
private readonly BasicEffect _effect;
private readonly VertexPositionTexture[] _quad;
internal LightmapRenderSystem(GraphicsDevice device, Renderer2D renderer, Lightmap lightmap)
{
_device = device;
_renderer = renderer;
_lightmap = lightmap;
_effect = new BasicEffect(device)
{
TextureEnabled = true,
VertexColorEnabled = false,
World = Matrix.Identity,
};
var x0 = lightmap.Origin.X;
var y0 = lightmap.Origin.Y;
var x1 = x0 + lightmap.Width * lightmap.CellSize;
var y1 = y0 + lightmap.Height * lightmap.CellSize;
_quad =
[
new VertexPositionTexture(new Vector3(x0, y0, 0f), new Vector2(0f, 0f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
new VertexPositionTexture(new Vector3(x1, y0, 0f), new Vector2(1f, 0f)),
new VertexPositionTexture(new Vector3(x1, y1, 0f), new Vector2(1f, 1f)),
new VertexPositionTexture(new Vector3(x0, y1, 0f), new Vector2(0f, 1f)),
];
}
/// <inheritdoc />
protected override void OnUpdateGroup()
{
var camera = _renderer.Camera;
_effect.View = camera.View;
_effect.Projection = camera.Projection;
_effect.Texture = _lightmap.Texture;
var previousViewport = _device.Viewport;
var mapping = camera.Mapping;
_device.Viewport = new Viewport(
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(camera.VirtualHeight * mapping.Scale)
);
_device.BlendState = Multiply;
_device.SamplerStates[0] = SamplerState.LinearClamp;
_device.DepthStencilState = DepthStencilState.None;
_device.RasterizerState = RasterizerState.CullNone;
foreach (var pass in _effect.CurrentTechnique.Passes)
{
pass.Apply();
_device.DrawUserPrimitives(PrimitiveType.TriangleList, _quad, 0, 2);
}
_device.Viewport = previousViewport;
_device.BlendState = BlendState.AlphaBlend;
}
}
@@ -0,0 +1,21 @@
using Friflo.Engine.ECS;
using Microsoft.Xna.Framework;
namespace MrGameEng.Lighting;
/// <summary>
/// A point light: an entity with this component plus a <see cref="MrGameEng.Graphics.Transform2D"/>
/// adds light around its world position, attenuating to zero at <see cref="Radius"/> and casting
/// grid-traced shadows behind occluders. Picked up by the lighting system into the lightmap.
/// </summary>
public struct PointLight : IComponent
{
/// <summary>Reach of the light in world units (light fades to zero at this distance).</summary>
public float Radius;
/// <summary>Light tint (reserved for colored lights; intensity currently drives brightness).</summary>
public Color Color;
/// <summary>Peak brightness added at the light's centre (0..1+).</summary>
public float Intensity;
}
@@ -1,11 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Graphics.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+16 -12
View File
@@ -96,20 +96,24 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
}
_renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count));
Parallel.For(0, _segments.Count, segmentIndex =>
{
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
Parallel.For(
0,
_segments.Count,
segmentIndex =>
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
_renderer.EndChunk(segmentIndex, in writer);
});
_renderer.EndChunk(segmentIndex, in writer);
}
);
_renderer.CommitChunkedSubmit();
}
}
+128 -30
View File
@@ -17,7 +17,9 @@ public sealed class Renderer2D : IDisposable
private const int MaxQuadsPerDraw = 8192;
private const int ParallelBlock = 4096;
private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
private static readonly int VertexStride = VertexPositionColorTexture
.VertexDeclaration
.VertexStride;
/// <summary>Render layer registry. Register layers before the first frame.</summary>
public LayerRegistry Layers { get; } = new();
@@ -25,6 +27,13 @@ public sealed class Renderer2D : IDisposable
/// <summary>Camera state of the current frame. Valid between BeginFrame and the next BeginFrame.</summary>
public CameraState Camera { get; private set; }
/// <summary>
/// Global ambient light multiplied into every <see cref="LayerSpace.World"/> sprite — the engine
/// hook a day/night cycle drives. <see cref="Color.White"/> (default) leaves the scene unchanged;
/// screen-space layers (HUD overlays) are never tinted.
/// </summary>
public Color AmbientLight { get; set; } = Color.White;
/// <summary>Draw calls issued by the last <see cref="EndFrame"/>.</summary>
public int DrawCalls { get; private set; }
@@ -67,10 +76,15 @@ public sealed class Renderer2D : IDisposable
{
_device = device;
_options = options ?? new Renderer2DOptions();
ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options));
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
device,
VertexPositionColorTexture.VertexDeclaration,
_vertices.Length * 2,
BufferUsage.WriteOnly
);
_effect = new BasicEffect(device)
{
@@ -90,7 +104,11 @@ public sealed class Renderer2D : IDisposable
var (virtualW, virtualH, mapping) = ResolveVirtualResolution();
Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping);
_screenCamera = CameraMath.Compute(
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping);
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)),
virtualW,
virtualH,
mapping
);
_batcher.Clear();
SubmittedSprites = 0;
@@ -154,14 +172,18 @@ public sealed class Renderer2D : IDisposable
if (count >= _options.ParallelThreshold)
{
var blocks = (count + ParallelBlock - 1) / ParallelBlock;
Parallel.For(0, blocks, block =>
{
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
Parallel.For(
0,
blocks,
block =>
{
BuildVertex(order, i);
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
{
BuildVertex(order, i);
}
}
});
);
}
else
{
@@ -189,7 +211,14 @@ public sealed class Renderer2D : IDisposable
hint = SetDataOptions.Discard;
}
_vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint);
_vertexBuffer.SetData(
_ringBaseVertex * VertexStride,
_vertices,
0,
vertexCount,
VertexStride,
hint
);
_ringCursor = _ringBaseVertex + vertexCount;
var uploadEnd = Stopwatch.GetTimestamp();
UploadMs = ToMs(uploadEnd - buildEnd);
@@ -201,7 +230,27 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer;
// Виртуальное разрешение рисуется в letterbox-прямоугольник — тот же, по которому
// считают ScreenToWorld/WorldToScreen; иначе картинка растягивается мимо маппинга.
var previousViewport = _device.Viewport;
var letterboxed = _options.VirtualResolution is not null;
if (letterboxed)
{
var mapping = Camera.Mapping;
_device.Viewport = new Viewport(
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(Camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(Camera.VirtualHeight * mapping.Scale)
);
}
DrawBatches(order, count);
if (letterboxed)
{
_device.Viewport = previousViewport;
}
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
}
@@ -227,9 +276,14 @@ public sealed class Renderer2D : IDisposable
_batcher.BeginChunks(chunkLengths);
}
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex);
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
_batcher.GetChunkWriter(chunkIndex);
internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite)
internal void SubmitInto(
ref SpriteChunkWriter writer,
in Transform2D transform,
in Sprite sprite
)
{
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{
@@ -242,7 +296,8 @@ public sealed class Renderer2D : IDisposable
}
}
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer);
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) =>
_batcher.EndChunk(chunkIndex, in writer);
internal void CommitChunkedSubmit()
{
@@ -258,7 +313,11 @@ public sealed class Renderer2D : IDisposable
}
private SubmitResult TryBuildInstance(
in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key)
in Transform2D transform,
in Sprite sprite,
out SpriteInstance instance,
out ulong key
)
{
instance = default;
key = 0;
@@ -268,23 +327,39 @@ public sealed class Renderer2D : IDisposable
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
if (
layer.Space == LayerSpace.World
&& !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)
)
{
return SubmitResult.Culled;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
// Y-sort по пивоту (Transform2D.Position), а не по центру квада: спрайты разной
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth;
// Ambient light tints world sprites (day/night); screen-space overlays stay at full brightness.
var color =
layer.Space == LayerSpace.World && AmbientLight != Color.White
? new Color(sprite.Color.ToVector4() * AmbientLight.ToVector4())
: sprite.Color;
instance = new SpriteInstance
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
HalfSize =
new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y)
/ 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Color = color,
Flip = sprite.Flip,
Layer = sprite.Layer.Value,
};
@@ -297,7 +372,8 @@ public sealed class Renderer2D : IDisposable
if (!_begun)
{
throw new InvalidOperationException(
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?).");
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?)."
);
}
}
@@ -309,8 +385,11 @@ public sealed class Renderer2D : IDisposable
return (viewport.Width, viewport.Height, ViewportMapping.Identity);
}
return (virtualSize.X, virtualSize.Y,
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
return (
virtualSize.X,
virtualSize.Y,
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)
);
}
private void BuildVertex(int[] order, int i)
@@ -333,7 +412,8 @@ public sealed class Renderer2D : IDisposable
(v0, v1) = (v1, v0);
}
Vector2 rx, ry;
Vector2 rx,
ry;
if (instance.Rotation == 0f)
{
rx = new Vector2(instance.HalfSize.X, 0f);
@@ -410,7 +490,11 @@ public sealed class Renderer2D : IDisposable
{
pass.Apply();
_device.DrawIndexedPrimitives(
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
PrimitiveType.TriangleList,
_ringBaseVertex + firstQuad * 4,
0,
quads * 2
);
DrawCalls++;
}
@@ -439,15 +523,24 @@ public sealed class Renderer2D : IDisposable
{
_vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
_device,
VertexPositionColorTexture.VertexDeclaration,
wantedBuffer,
BufferUsage.WriteOnly
);
_ringCursor = 0;
}
}
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
private static float ToMs(long timestampDelta) =>
(float)timestampDelta * 1000f / Stopwatch.Frequency;
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
new(new Vector3(position, 0f), color, new Vector2(u, v));
private static VertexPositionColorTexture Vertex(
Vector2 position,
Color color,
float u,
float v
) => new(new Vector3(position, 0f), color, new Vector2(u, v));
private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device)
{
@@ -464,7 +557,12 @@ public sealed class Renderer2D : IDisposable
indices[index + 5] = (ushort)(vertex + 3);
}
var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly);
var buffer = new IndexBuffer(
device,
IndexElementSize.SixteenBits,
indices.Length,
BufferUsage.WriteOnly
);
buffer.SetData(indices);
return buffer;
}
@@ -13,15 +13,25 @@ public static class SceneGraphicsExtensions
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
/// </summary>
public static Renderer2D UseRenderer2D(
this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems)
this Scene scene,
Renderer2DOptions? options = null,
params BaseSystem[] extraDrawSystems
)
{
var services = scene.Context.Services;
var renderer = services.GetOrDefault<Renderer2D>();
if (renderer is null)
{
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
renderer = new Renderer2D(scene.Context.GetGraphicsDevice(), options);
services.Add(renderer);
}
else if (options is not null)
{
Log.Warning(
"UseRenderer2D: the renderer already exists, the passed options are ignored "
+ "(Renderer2D is a shared service configured by its first user)."
);
}
scene.DrawSystems.Add(new CameraSystem(renderer));
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));
+9 -2
View File
@@ -19,11 +19,18 @@ public sealed class SpriteAnimationClip
public float Duration => Frames.Count / FramesPerSecond;
/// <summary>Creates a clip.</summary>
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
public SpriteAnimationClip(
IReadOnlyList<Texture2DRegion> frames,
float framesPerSecond = 12f,
bool loop = true
)
{
if (frames.Count == 0)
{
throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames));
throw new ArgumentException(
"An animation clip needs at least one frame.",
nameof(frames)
);
}
Frames = frames;
+2 -1
View File
@@ -130,7 +130,8 @@ public sealed class SpriteBatcher
// Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым
// разрядам (один слой, одна глубина) пропускаются целиком.
ulong orBits = 0, andBits = ~0UL;
ulong orBits = 0,
andBits = ~0UL;
for (var i = 0; i < n; i++)
{
orBits |= _keys[i];
+3 -1
View File
@@ -9,7 +9,9 @@ public static class SpriteSortKey
{
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
public static ulong Make(byte layer, float depth, int textureKey) =>
((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF);
((ulong)layer << 56)
| ((ulong)DepthToSortableBits(depth) << 24)
| ((uint)textureKey & 0xFF_FFFF);
/// <summary>
/// Maps a float to bits whose unsigned order matches the float order
+11 -8
View File
@@ -35,22 +35,25 @@ public sealed class Texture2DRegion
Texture = texture;
Bounds = bounds;
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
Diagonal = MathF.Sqrt(
(float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height
);
// UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления.
// texture может быть null только в headless-тестах.
if (texture is not null)
{
U0 = bounds.X / (float)texture.Width;
V0 = bounds.Y / (float)texture.Height;
U1 = (bounds.X + bounds.Width) / (float)texture.Width;
V1 = (bounds.Y + bounds.Height) / (float)texture.Height;
// Полутексельный inset: UV идут от центра крайнего текселя к центру крайнего, а не по
// самым кромкам региона. С point-фильтрацией это гарантирует, что края тайла никогда не
// сэмплят прозрачный «жёлоб» атласа (Padding) — иначе при дробном зуме видны чёрные швы.
U0 = (bounds.X + 0.5f) / texture.Width;
V0 = (bounds.Y + 0.5f) / texture.Height;
U1 = (bounds.X + bounds.Width - 0.5f) / texture.Width;
V1 = (bounds.Y + bounds.Height - 0.5f) / texture.Height;
}
}
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture)
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
{
}
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) { }
}
@@ -12,8 +12,11 @@ public static class SceneTilemapExtensions
/// </summary>
public static void UseTilemaps(this Scene scene)
{
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first.");
var renderer =
scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException(
"UseTilemaps requires UseRenderer2D to be called first."
);
var systems = scene.DrawSystems.ChildSystems;
for (var i = 0; i < systems.Count; i++)
@@ -25,6 +28,8 @@ public static class SceneTilemapExtensions
}
}
throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?).");
throw new InvalidOperationException(
"RenderFlushSystem not found (is UseRenderer2D wired on this scene?)."
);
}
}
@@ -53,7 +53,9 @@ public sealed class TileGrid
if (!Contains(x, y))
{
throw new ArgumentOutOfRangeException(
nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid.");
nameof(x),
$"Cell ({x},{y}) is outside the {Width}x{Height} grid."
);
}
}
}
@@ -10,9 +10,7 @@ public readonly record struct TileDef(Texture2DRegion Region, Color Color)
{
/// <summary>Creates an untinted tile.</summary>
public TileDef(Texture2DRegion region)
: this(region, Color.White)
{
}
: this(region, Color.White) { }
}
/// <summary>
@@ -11,8 +11,16 @@ public static class TilemapMath
/// Returns false when the map is entirely outside the rectangle.
/// </summary>
public static bool VisibleCells(
in RectF cullRect, Vector2 origin, float tileSize, int width, int height,
out int x0, out int y0, out int x1, out int y1)
in RectF cullRect,
Vector2 origin,
float tileSize,
int width,
int height,
out int x0,
out int y0,
out int x1,
out int y1
)
{
x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize));
y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize));
@@ -25,7 +25,11 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
{
foreach (ref readonly var map in maps.Span)
{
if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f)
if (
map.Grid is not { } grid
|| map.TileSet is not { } tileSet
|| map.TileSize <= 0f
)
{
continue;
}
@@ -36,9 +40,19 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
// Screen-space слои не куллятся камерой — рисуем весь грид.
SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1);
}
else if (TilemapMath.VisibleCells(
in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height,
out var x0, out var y0, out var x1, out var y1))
else if (
TilemapMath.VisibleCells(
in cullRect,
map.Origin,
map.TileSize,
grid.Width,
grid.Height,
out var x0,
out var y0,
out var x1,
out var y1
)
)
{
SubmitRange(in map, grid, tileSet, x0, y0, x1, y1);
}
@@ -46,7 +60,15 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
}
}
private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1)
private void SubmitRange(
in Tilemap map,
TileGrid grid,
TileSet tileSet,
int x0,
int y0,
int x1,
int y1
)
{
for (var y = y0; y <= y1; y++)
{
@@ -62,12 +84,14 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
var region = def.Region;
var transform = new Transform2D(
map.Origin + new Vector2(x, y) * map.TileSize,
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height));
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height)
);
var sprite = new Sprite(region, map.Layer)
{
Color = map.Color == Color.White
? def.Color
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
Color =
map.Color == Color.White
? def.Color
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
Depth = map.Depth,
};
_renderer.Submit(in transform, in sprite);
@@ -1,11 +1,16 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>
/// The engine's game loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/> and drives the
/// active scene's update and draw phases.
/// The engine's windowed game-loop host. Wraps MonoGame's <see cref="Game"/>: owns the
/// <see cref="EngineContext"/>, advances the <see cref="GameClock"/>, drives the active
/// scene's update and draw phases and renders scene-transition overlays. The
/// <see cref="GraphicsDevice"/> is published as a service so graphics modules can reach it
/// through the context. For a loop without a window or GPU see
/// <see cref="HeadlessHost"/> in the core.
/// </summary>
public class GameHost : Game
{
@@ -17,6 +22,7 @@ public class GameHost : Game
private readonly GameHostOptions _options;
private readonly Scene _initialScene;
private TransitionRenderer? _transitionRenderer;
/// <summary>Creates a host that starts with <paramref name="initialScene"/>.</summary>
public GameHost(GameHostOptions options, Scene initialScene)
@@ -29,6 +35,9 @@ public class GameHost : Game
PreferredBackBufferWidth = options.Width,
PreferredBackBufferHeight = options.Height,
IsFullScreen = options.Fullscreen,
// Borderless, как и обещает GameHostOptions.Fullscreen; по умолчанию MonoGame
// делает эксклюзивное переключение видеорежима монитора.
HardwareModeSwitch = false,
SynchronizeWithVerticalRetrace = options.VSync,
};
@@ -45,7 +54,7 @@ public class GameHost : Game
{
Window.Title = _options.Title;
Window.AllowUserResizing = _options.AllowResizing;
Context.AttachGraphicsDevice(GraphicsDevice);
Context.Services.Add(GraphicsDevice);
Context.Services.Add(Window);
Context.Services.Add<Game>(this);
base.Initialize();
@@ -65,9 +74,25 @@ public class GameHost : Game
{
GraphicsDevice.Clear(_options.ClearColor);
Context.Scenes.Draw(Context.Clock);
DrawTransitionOverlay();
base.Draw(gameTime);
}
private void DrawTransitionOverlay()
{
if (Context.Scenes.ActiveTransition is not OverlayTransition transition)
{
return;
}
_transitionRenderer ??= new TransitionRenderer(GraphicsDevice);
transition.Draw(
_transitionRenderer,
Context.Scenes.TransitionCoverage,
Context.Scenes.TransitionPhase
);
}
/// <inheritdoc />
protected override void OnExiting(object sender, ExitingEventArgs args)
{
@@ -75,4 +100,19 @@ public class GameHost : Game
Context.Scenes.ApplyPending();
base.OnExiting(sender, args);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
_transitionRenderer?.Dispose();
_transitionRenderer = null;
// GraphicsDevice зарегистрирован как сервис, но им владеет MonoGame:
// base.Dispose сам его освобождает, реестру трогать нельзя.
Context.DisposeOwnedResources(this, GraphicsDevice);
}
base.Dispose(disposing);
}
}
@@ -1,6 +1,6 @@
using Microsoft.Xna.Framework;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>Window and loop settings for <see cref="GameHost"/>.</summary>
public sealed class GameHostOptions
@@ -7,7 +7,8 @@ namespace MrGameEng.Input;
/// gamepad buttons. Query by action instead of device, rebind at runtime.
/// </summary>
/// <typeparam name="TAction">Enum (or any value) identifying the game's actions.</typeparam>
public sealed class ActionMap<TAction> where TAction : notnull
public sealed class ActionMap<TAction>
where TAction : notnull
{
private readonly InputManager _input;
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
@@ -18,37 +19,49 @@ public sealed class ActionMap<TAction> where TAction : notnull
public ActionMap(InputManager input) => _input = input;
/// <summary>Adds a keyboard binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null));
public ActionMap<TAction> Bind(TAction action, Keys key) =>
Add(action, new Binding(key, null, null));
/// <summary>Adds a mouse-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null));
public ActionMap<TAction> Bind(TAction action, MouseButton button) =>
Add(action, new Binding(null, button, null));
/// <summary>Adds a gamepad-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button));
public ActionMap<TAction> Bind(TAction action, Buttons button) =>
Add(action, new Binding(null, null, button));
/// <summary>Removes every binding of <paramref name="action"/> (for rebinding).</summary>
public void Unbind(TAction action) => _bindings.Remove(action);
/// <summary>True while any binding of the action is held down.</summary>
public bool IsDown(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyDown(k)) ||
(b.Mouse is { } m && input.IsMouseDown(m)) ||
(b.GamePad is { } g && input.IsButtonDown(g)));
public bool IsDown(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyDown(k))
|| (b.Mouse is { } m && input.IsMouseDown(m))
|| (b.GamePad is { } g && input.IsButtonDown(g))
);
/// <summary>True only on the frame any binding of the action went down.</summary>
public bool IsPressed(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyPressed(k)) ||
(b.Mouse is { } m && input.IsMousePressed(m)) ||
(b.GamePad is { } g && input.IsButtonPressed(g)));
public bool IsPressed(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyPressed(k))
|| (b.Mouse is { } m && input.IsMousePressed(m))
|| (b.GamePad is { } g && input.IsButtonPressed(g))
);
/// <summary>True only on the frame any binding of the action went up.</summary>
public bool IsReleased(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyReleased(k)) ||
(b.Mouse is { } m && input.IsMouseReleased(m)) ||
(b.GamePad is { } g && input.IsButtonReleased(g)));
public bool IsReleased(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyReleased(k))
|| (b.Mouse is { } m && input.IsMouseReleased(m))
|| (b.GamePad is { } g && input.IsButtonReleased(g))
);
/// <summary>Composes -1/0/+1 from two digital actions (e.g. move left / move right).</summary>
public float GetAxis(TAction negative, TAction positive) =>
@@ -20,10 +20,13 @@ public enum MouseButton
/// Polls keyboard, mouse and gamepad once per frame and keeps the previous frame's state,
/// enabling edge queries (<c>Pressed</c> = went down this frame, <c>Released</c> = went up).
/// Registered as a service by <c>scene.UseInput()</c>; polled by <see cref="InputSystem"/>
/// at the start of the update phase.
/// at the start of the update phase. While <see cref="MrGameEng.Core.InputCapture.Captured"/>
/// is set (e.g. the developer console is open), game-facing input reads as released.
/// </summary>
public sealed class InputManager
{
private readonly MrGameEng.Core.InputCapture? _capture;
private KeyboardState _keyboard;
private KeyboardState _previousKeyboard;
private MouseState _mouse;
@@ -31,11 +34,38 @@ public sealed class InputManager
private GamePadState _gamePad;
private GamePadState _previousGamePad;
/// <summary>
/// Creates a manager. With a <paramref name="capture"/>, input is suppressed while a UI
/// overlay holds it (keys/buttons read as released, mouse position and wheel freeze).
/// </summary>
public InputManager(MrGameEng.Core.InputCapture? capture = null) => _capture = capture;
/// <summary>Polls all devices. Called once per frame by <see cref="InputSystem"/>.</summary>
public void Update() => Apply(
Keyboard.GetState(),
Mouse.GetState(),
GamePad.GetState(PlayerIndex.One));
public void Update()
{
if (_capture?.Captured == true)
{
// Оверлей (консоль) захватил ввод: клавиши и кнопки считаются отпущенными,
// позиция мыши и счётчик колеса замораживаются — все дельты нулевые.
Apply(
default,
new MouseState(
_mouse.X,
_mouse.Y,
_mouse.ScrollWheelValue,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released
),
default
);
return;
}
Apply(Keyboard.GetState(), Mouse.GetState(), GamePad.GetState(PlayerIndex.One));
}
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
{
@@ -51,10 +81,12 @@ public sealed class InputManager
public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key);
/// <summary>True only on the frame the key went down.</summary>
public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
public bool IsKeyPressed(Keys key) =>
_keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
/// <summary>True only on the frame the key went up.</summary>
public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
public bool IsKeyReleased(Keys key) =>
_keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
/// <summary>Mouse cursor position in window pixels.</summary>
public Point MousePosition => _mouse.Position;
@@ -70,29 +102,34 @@ public sealed class InputManager
/// <summary>True only on the frame the mouse button went down.</summary>
public bool IsMousePressed(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released;
GetButton(_mouse, button) == ButtonState.Pressed
&& GetButton(_previousMouse, button) == ButtonState.Released;
/// <summary>True only on the frame the mouse button went up.</summary>
public bool IsMouseReleased(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed;
GetButton(_mouse, button) == ButtonState.Released
&& GetButton(_previousMouse, button) == ButtonState.Pressed;
/// <summary>True while the gamepad button is held down.</summary>
public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button);
/// <summary>True only on the frame the gamepad button went down.</summary>
public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
public bool IsButtonPressed(Buttons button) =>
_gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
/// <summary>True only on the frame the gamepad button went up.</summary>
public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
public bool IsButtonReleased(Buttons button) =>
_gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
/// <summary>Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world.</summary>
public Vector2 LeftStick => new(_gamePad.ThumbSticks.Left.X, -_gamePad.ThumbSticks.Left.Y);
private static ButtonState GetButton(in MouseState state, MouseButton button) => button switch
{
MouseButton.Left => state.LeftButton,
MouseButton.Right => state.RightButton,
MouseButton.Middle => state.MiddleButton,
_ => ButtonState.Released,
};
private static ButtonState GetButton(in MouseState state, MouseButton button) =>
button switch
{
MouseButton.Left => state.LeftButton,
MouseButton.Right => state.RightButton,
MouseButton.Middle => state.MiddleButton,
_ => ButtonState.Released,
};
}
@@ -29,7 +29,14 @@ public static class SceneInputExtensions
var input = services.GetOrDefault<InputManager>();
if (input is null)
{
input = new InputManager();
var capture = services.GetOrDefault<InputCapture>();
if (capture is null)
{
capture = new InputCapture();
services.Add(capture);
}
input = new InputManager(capture);
services.Add(input);
}
@@ -1,15 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FontStashSharp.MonoGame" />
<PackageReference Include="MonoGame.Framework.DesktopGL" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Host.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,18 +1,21 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
namespace MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// Minimal overlay renderer handed to <see cref="OverlayTransition.Draw"/>: fills rectangles
/// in normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// </summary>
public sealed class TransitionRenderer
public sealed class TransitionRenderer : IDisposable
{
private readonly GraphicsDevice _device;
private readonly BasicEffect _effect;
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
/// <summary>Disposes the GPU effect. Called by <see cref="GameHost"/> on shutdown.</summary>
public void Dispose() => _effect.Dispose();
internal TransitionRenderer(GraphicsDevice device)
{
_device = device;
+66
View File
@@ -0,0 +1,66 @@
using Microsoft.Xna.Framework;
using MrGameEng.Core;
namespace MrGameEng.Host;
/// <summary>
/// A scene transition that draws a full-screen overlay through a
/// <see cref="TransitionRenderer"/>. The timing state machine lives in
/// <see cref="SceneManager"/>; <see cref="GameHost"/> renders the overlay each draw while a
/// transition is active. Transitions are stateless and reusable.
/// </summary>
public abstract class OverlayTransition : Transition
{
/// <summary>Creates a transition with explicit phase durations.</summary>
protected OverlayTransition(float outDuration, float inDuration)
: base(outDuration, inDuration) { }
/// <summary>
/// Draws the overlay. <paramref name="coverage"/> is 0 (scene fully visible) to
/// 1 (scene fully covered); <paramref name="phase"/> tells which side of the switch this is.
/// </summary>
public abstract void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase);
}
/// <summary>Factories for the built-in visual scene transitions.</summary>
public static class Transitions
{
/// <summary>Fade through a solid color (black by default). Total duration is split between out and in.</summary>
public static Transition Fade(float duration = 0.6f, Color? color = null) =>
new FadeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
/// <summary>A curtain wiping across the screen (black by default). Total duration is split between out and in.</summary>
public static Transition Wipe(float duration = 0.6f, Color? color = null) =>
new WipeTransition(duration / 2f, duration / 2f, color ?? Color.Black);
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: OverlayTransition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: OverlayTransition(outDuration, inDuration)
{
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
{
renderer.Fill(0f, 0f, coverage, 1f, color);
}
else
{
renderer.Fill(1f - coverage, 0f, coverage, 1f, color);
}
}
}
}
@@ -1,15 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Input.Tests" />
</ItemGroup>
</Project>
+25
View File
@@ -0,0 +1,25 @@
namespace MrGameEng.Net;
/// <summary>
/// A bidirectional, reliable, ordered binary message channel (a WebSocket under the hood).
/// Receiving is poll-based to fit the simulation loop: incoming messages queue up on a
/// background reader and are drained with <see cref="TryReceive"/> from the tick. Sending
/// never blocks the caller. Implementations are safe to use from one simulation thread.
/// </summary>
public interface INetConnection
{
/// <summary>Connection id, unique within its owner (server-assigned; 0 for a client's own connection).</summary>
int Id { get; }
/// <summary>False once the peer disconnected or the connection failed; sends become no-ops.</summary>
bool IsOpen { get; }
/// <summary>Queues one binary message for delivery. No-op when the connection is closed.</summary>
void Send(ReadOnlySpan<byte> message);
/// <summary>Dequeues the next received binary message, if any.</summary>
bool TryReceive(out byte[] message);
/// <summary>Closes the connection.</summary>
void Close();
}
@@ -1,16 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Tilemaps.Tests" />
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Net.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Marks an entity as replicated and identifies it across the network. The server assigns
/// values (see <see cref="ReplicationServer.NextNetId"/>); the client creates a local
/// entity with the same <see cref="Value"/> when the first snapshot arrives.
/// </summary>
public struct NetId : IComponent
{
/// <summary>Network-wide entity id, unique per server world.</summary>
public int Value;
}
@@ -0,0 +1,137 @@
using Friflo.Engine.ECS;
using MrGameEng.Core;
namespace MrGameEng.Net;
/// <summary>
/// Client side of replication: applies snapshot messages from a
/// <see cref="ReplicationServer"/> to a local <see cref="EntityStore"/>. Unknown net ids
/// spawn local entities (carrying <see cref="NetId"/>), known ones get their changed
/// components overwritten, despawns delete. The game decorates replicated entities with
/// presentation components (sprites etc.) on top — replication never touches types outside
/// its <see cref="ReplicationSchema"/>.
/// </summary>
public sealed class ReplicationClient
{
/// <summary>Number of replicated entities currently alive locally.</summary>
public int EntityCount => _entities.Count;
/// <summary>Raised after an entity is created from a snapshot. Hook presentation setup here.</summary>
public event Action<Entity>? EntitySpawned;
private readonly ReplicationSchema _schema;
private readonly EntityStore _store;
private readonly Dictionary<int, Entity> _entities = [];
private bool _warnedVersion;
/// <summary>Creates a replication client writing into <paramref name="store"/>.</summary>
public ReplicationClient(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_store = store;
}
/// <summary>Applies every message queued on <paramref name="connection"/>.</summary>
public void Pump(INetConnection connection)
{
while (connection.TryReceive(out var message))
{
Apply(message);
}
}
/// <summary>
/// Deletes every replicated entity and forgets all net ids. Call before reconnecting: the
/// fresh connection receives the full world again with a clean id space, so stale entities
/// from the previous session don't linger as duplicates.
/// </summary>
public void Clear()
{
foreach (var entity in _entities.Values)
{
entity.DeleteEntity();
}
_entities.Clear();
}
/// <summary>Applies one snapshot message to the local store.</summary>
public void Apply(byte[] message)
{
using var reader = new BinaryReader(new MemoryStream(message));
// Заголовок: тип(1) + версия(1). Короче — точно не наш снапшот.
if (message.Length < 2 || reader.ReadByte() != ReplicationMessage.Snapshot)
{
return; // незнакомый тип сообщения — пропускаем, это не снапшот
}
var version = reader.ReadByte();
if (version != ReplicationMessage.ProtocolVersion)
{
if (!_warnedVersion)
{
_warnedVersion = true;
Log.Warning(
$"Replication protocol mismatch: server v{version}, client "
+ $"v{ReplicationMessage.ProtocolVersion} — snapshots dropped. Schemas out of sync."
);
}
return;
}
var slots = _schema.Slots;
try
{
var count = reader.ReadInt32();
for (var record = 0; record < count; record++)
{
var netId = reader.ReadInt32();
var op = reader.ReadByte();
if (op == ReplicationMessage.OpDespawn)
{
if (_entities.Remove(netId, out var dead))
{
dead.DeleteEntity();
}
continue;
}
var mask = reader.ReadUInt32();
var spawned = false;
if (!_entities.TryGetValue(netId, out var entity))
{
entity = _store.CreateEntity(new NetId { Value = netId });
_entities[netId] = entity;
spawned = true;
}
foreach (var slot in slots)
{
if ((mask & (1u << slot.Bit)) == 0)
{
continue;
}
var data = reader.ReadBytes(slot.Size);
if (data.Length < slot.Size)
{
return; // снапшот оборван на полпути — дальше читать нечего
}
slot.Apply(entity, data, 0);
}
if (spawned)
{
EntitySpawned?.Invoke(entity);
}
}
}
catch (EndOfStreamException)
{
// Структурно битый/усечённый снапшот — игнорируем остаток, соединение не роняем.
}
}
}
@@ -0,0 +1,64 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// The set of component types a game replicates, registered in the same order on the
/// server and every client (the order defines the wire ids). Components must be
/// unmanaged structs — they are blitted to the wire as raw bytes, so server and client
/// must run on the same engine version. Up to 32 types.
/// </summary>
public sealed class ReplicationSchema
{
internal sealed class ComponentSlot
{
public required int Bit;
public required int Size;
public required Func<Entity, byte[], bool> TryWrite;
public required Action<Entity, byte[], int> Apply;
}
internal readonly List<ComponentSlot> Slots = [];
/// <summary>
/// Registers component type <typeparamref name="T"/> for replication. Returns this
/// schema for fluent chaining.
/// </summary>
public ReplicationSchema Register<T>()
where T : unmanaged, IComponent
{
if (Slots.Count == 32)
{
throw new InvalidOperationException(
"ReplicationSchema supports at most 32 component types."
);
}
var size = Unsafe.SizeOf<T>();
Slots.Add(
new ComponentSlot
{
Bit = Slots.Count,
Size = size,
TryWrite = (entity, buffer) =>
{
if (!entity.HasComponent<T>())
{
return false;
}
MemoryMarshal.Write(buffer, in entity.GetComponent<T>());
return true;
},
Apply = (entity, data, offset) =>
{
var value = MemoryMarshal.Read<T>(data.AsSpan(offset, size));
entity.AddComponent(value);
},
}
);
return this;
}
}
@@ -0,0 +1,183 @@
using Friflo.Engine.ECS;
namespace MrGameEng.Net;
/// <summary>
/// Server-authoritative component replication. Each call to <see cref="Send"/> snapshots
/// every entity carrying <see cref="NetId"/> and sends each connection only what changed
/// since that connection's previous snapshot (per-component deltas; a new connection gets
/// the full state the same way). Deltas need no acknowledgements because the transport is
/// reliable and ordered. Call at the desired send rate (e.g. every Nth simulation tick),
/// from the simulation thread.
/// </summary>
public sealed class ReplicationServer
{
private sealed class ConnectionState
{
// По netId: последний отправленный блоб каждого зарегистрированного компонента.
public readonly Dictionary<int, byte[]?[]> LastSent = [];
}
private readonly ReplicationSchema _schema;
private readonly ArchetypeQuery<NetId> _query;
private readonly Dictionary<INetConnection, ConnectionState> _states = [];
// Снапшот текущего тика, переиспользуется между соединениями.
private readonly List<(int NetId, byte[]?[] Components)> _current = [];
private readonly HashSet<int> _currentIds = [];
private int _nextNetId;
/// <summary>Creates a replication server over <paramref name="store"/>.</summary>
public ReplicationServer(ReplicationSchema schema, EntityStore store)
{
_schema = schema;
_query = store.Query<NetId>();
}
/// <summary>Allocates the next free network id for a newly spawned replicated entity.</summary>
public int NextNetId() => ++_nextNetId;
/// <summary>
/// Snapshots the world once and sends per-connection deltas. Closed connections are
/// forgotten; brand-new ones receive the full state.
/// </summary>
public void Send(IReadOnlyList<INetConnection> connections)
{
CaptureCurrentState();
foreach (var connection in connections)
{
if (!connection.IsOpen)
{
continue;
}
if (!_states.TryGetValue(connection, out var state))
{
state = new ConnectionState();
_states[connection] = state;
}
var message = BuildDelta(state);
if (message is not null)
{
connection.Send(message);
}
}
// Забываем состояние умерших соединений, чтобы не копить мусор.
foreach (var dead in _states.Keys.Where(c => !c.IsOpen).ToList())
{
_states.Remove(dead);
}
}
private void CaptureCurrentState()
{
_current.Clear();
_currentIds.Clear();
var slots = _schema.Slots;
_query.ForEachEntity(
(ref NetId netId, Entity entity) =>
{
var components = new byte[]?[slots.Count];
foreach (var slot in slots)
{
var buffer = new byte[slot.Size];
components[slot.Bit] = slot.TryWrite(entity, buffer) ? buffer : null;
}
_current.Add((netId.Value, components));
_currentIds.Add(netId.Value);
}
);
}
private byte[]? BuildDelta(ConnectionState state)
{
using var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(ReplicationMessage.Snapshot);
writer.Write(ReplicationMessage.ProtocolVersion); // версия формата — клиент отвергает чужую
var countPosition = stream.Position;
writer.Write(0); // количество записей, допишем в конце
var records = 0;
foreach (var (netId, components) in _current)
{
if (!state.LastSent.TryGetValue(netId, out var lastSent))
{
lastSent = new byte[]?[components.Length];
state.LastSent[netId] = lastSent;
}
uint mask = 0;
for (var bit = 0; bit < components.Length; bit++)
{
var current = components[bit];
if (current is null)
{
continue;
}
if (lastSent[bit] is null || !current.AsSpan().SequenceEqual(lastSent[bit]))
{
mask |= 1u << bit;
lastSent[bit] = current;
}
}
if (mask == 0)
{
continue;
}
writer.Write(netId);
writer.Write(ReplicationMessage.OpUpsert);
writer.Write(mask);
for (var bit = 0; bit < components.Length; bit++)
{
if ((mask & (1u << bit)) != 0)
{
writer.Write(components[bit]!);
}
}
records++;
}
// Сущности, которые соединение знает, а в мире их больше нет.
foreach (var known in state.LastSent.Keys.Where(id => !_currentIds.Contains(id)).ToList())
{
state.LastSent.Remove(known);
writer.Write(known);
writer.Write(ReplicationMessage.OpDespawn);
records++;
}
if (records == 0)
{
return null;
}
stream.Position = countPosition;
writer.Write(records);
return stream.ToArray();
}
}
/// <summary>Wire constants shared by <see cref="ReplicationServer"/> and <see cref="ReplicationClient"/>.</summary>
internal static class ReplicationMessage
{
internal const byte Snapshot = 1;
/// <summary>
/// Wire-format version. Bump whenever the snapshot layout or the meaning of the schema's
/// component blits changes; a client receiving a mismatched version drops the message
/// instead of decoding garbage (guards against a desync between server and client schemas).
/// </summary>
internal const byte ProtocolVersion = 1;
internal const byte OpUpsert = 0;
internal const byte OpDespawn = 1;
}
+149
View File
@@ -0,0 +1,149 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace MrGameEng.Net;
/// <summary>
/// Client side of <see cref="INetConnection"/>: a thin wrapper over the BCL
/// <see cref="ClientWebSocket"/>, which works on desktop and inside Blazor WebAssembly
/// (where it maps to the browser's WebSocket). Receiving runs on a background task into a
/// queue; sends are chained fire-and-forget so the caller — and the browser's single
/// thread — never blocks.
/// </summary>
public sealed class WebSocketClient : INetConnection, IDisposable
{
/// <inheritdoc />
public int Id => 0;
/// <inheritdoc />
public bool IsOpen => !_closed && _socket.State == WebSocketState.Open;
private readonly ClientWebSocket _socket;
private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly CancellationTokenSource _shutdown = new();
private Task _sendTail = Task.CompletedTask;
private volatile bool _closed;
private WebSocketClient(ClientWebSocket socket) => _socket = socket;
/// <summary>
/// Connects to <paramref name="uri"/> (ws:// or wss://) and starts the receive loop.
/// </summary>
public static async Task<WebSocketClient> ConnectAsync(
Uri uri,
CancellationToken cancellationToken = default
)
{
var socket = new ClientWebSocket();
await socket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false);
var client = new WebSocketClient(socket);
_ = Task.Run(client.ReceiveLoop);
return client;
}
/// <inheritdoc />
public void Send(ReadOnlySpan<byte> message)
{
if (!IsOpen)
{
return;
}
var copy = message.ToArray();
// Отправки сцеплены в хвост: ClientWebSocket не терпит параллельных SendAsync,
// а блокировать поток нельзя (в wasm это смерть).
lock (_shutdown)
{
_sendTail = _sendTail.ContinueWith(
_ => SendCore(copy),
CancellationToken.None,
TaskContinuationOptions.None,
TaskScheduler.Default
);
}
}
private async Task SendCore(byte[] message)
{
try
{
await _socket
.SendAsync(
message,
WebSocketMessageType.Binary,
endOfMessage: true,
_shutdown.Token
)
.ConfigureAwait(false);
}
catch (Exception)
{
Close();
}
}
/// <inheritdoc />
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
/// <inheritdoc />
public void Close()
{
if (_closed)
{
return;
}
_closed = true;
_shutdown.Cancel();
_socket.Dispose();
}
/// <summary>Closes the connection.</summary>
public void Dispose() => Close();
/// <summary>Largest reassembled message accepted from the server before the connection is dropped.</summary>
public const int MaxMessageBytes = 16 * 1024 * 1024;
private async Task ReceiveLoop()
{
var buffer = new byte[64 * 1024];
var message = new MemoryStream();
try
{
while (!_closed)
{
var result = await _socket
.ReceiveAsync(buffer, _shutdown.Token)
.ConfigureAwait(false);
if (result.MessageType == WebSocketMessageType.Close)
{
break;
}
if (message.Length + result.Count > MaxMessageBytes)
{
break; // сервер шлёт ненормально большое сообщение — рвём соединение
}
message.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
{
if (result.MessageType == WebSocketMessageType.Binary)
{
_inbox.Enqueue(message.ToArray());
}
message.SetLength(0);
}
}
}
catch (Exception)
{
// обрыв или закрытие — штатное завершение цикла
}
finally
{
Close();
}
}
}
+248
View File
@@ -0,0 +1,248 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace MrGameEng.Net;
/// <summary>WebSocket frame opcodes used by the server.</summary>
internal enum WebSocketOpcode : byte
{
Continuation = 0x0,
Text = 0x1,
Binary = 0x2,
Close = 0x8,
Ping = 0x9,
Pong = 0xA,
}
/// <summary>
/// Minimal RFC 6455 building blocks for the server side: the upgrade handshake and frame
/// encode/decode over a <see cref="Stream"/>. Kept free of sockets so the protocol logic is
/// unit-testable; <see cref="WebSocketServer"/> wires it to TCP.
/// </summary>
internal static class WebSocketProtocol
{
private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
/// <summary>Computes the Sec-WebSocket-Accept value for a client's Sec-WebSocket-Key.</summary>
internal static string AcceptKey(string secWebSocketKey)
{
var bytes = Encoding.ASCII.GetBytes(secWebSocketKey + HandshakeGuid);
return Convert.ToBase64String(SHA1.HashData(bytes));
}
/// <summary>
/// Reads the HTTP upgrade request from <paramref name="stream"/> (up to the blank line)
/// and extracts the Sec-WebSocket-Key header. Returns false on a malformed request.
/// </summary>
internal static bool TryReadHandshakeKey(Stream stream, out string key)
{
key = "";
var buffer = new byte[8 * 1024];
var length = 0;
// Читаем до конца заголовков (\r\n\r\n); запрос маленький, побайтовое чтение не больно.
while (length < buffer.Length)
{
var read = stream.Read(buffer, length, 1);
if (read == 0)
{
return false;
}
length++;
if (
length >= 4
&& buffer[length - 4] == (byte)'\r'
&& buffer[length - 3] == (byte)'\n'
&& buffer[length - 2] == (byte)'\r'
&& buffer[length - 1] == (byte)'\n'
)
{
break;
}
}
var request = Encoding.ASCII.GetString(buffer, 0, length);
foreach (var line in request.Split("\r\n"))
{
var separator = line.IndexOf(':');
if (separator < 0)
{
continue;
}
if (
line[..separator]
.Trim()
.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase)
)
{
key = line[(separator + 1)..].Trim();
return key.Length > 0;
}
}
return false;
}
/// <summary>Writes the 101 Switching Protocols response completing the handshake.</summary>
internal static void WriteHandshakeResponse(Stream stream, string secWebSocketKey)
{
var response =
"HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ $"Sec-WebSocket-Accept: {AcceptKey(secWebSocketKey)}\r\n"
+ "\r\n";
var bytes = Encoding.ASCII.GetBytes(response);
stream.Write(bytes, 0, bytes.Length);
}
/// <summary>
/// Encodes one complete (FIN) frame. Server frames are unmasked per RFC 6455;
/// <paramref name="maskKey"/> is for tests that emulate a client.
/// </summary>
internal static byte[] EncodeFrame(
ReadOnlySpan<byte> payload,
WebSocketOpcode opcode,
byte[]? maskKey = null
)
{
var masked = maskKey is not null;
var headerLength =
2
+ payload.Length switch
{
<= 125 => 0,
<= ushort.MaxValue => 2,
_ => 8,
};
var frame = new byte[headerLength + (masked ? 4 : 0) + payload.Length];
frame[0] = (byte)(0x80 | (byte)opcode);
switch (payload.Length)
{
case <= 125:
frame[1] = (byte)payload.Length;
break;
case <= ushort.MaxValue:
frame[1] = 126;
BinaryPrimitives.WriteUInt16BigEndian(frame.AsSpan(2), (ushort)payload.Length);
break;
default:
frame[1] = 127;
BinaryPrimitives.WriteUInt64BigEndian(frame.AsSpan(2), (ulong)payload.Length);
break;
}
var offset = headerLength;
if (masked)
{
frame[1] |= 0x80;
maskKey!.CopyTo(frame, offset);
offset += 4;
for (var i = 0; i < payload.Length; i++)
{
frame[offset + i] = (byte)(payload[i] ^ maskKey[i % 4]);
}
}
else
{
payload.CopyTo(frame.AsSpan(offset));
}
return frame;
}
/// <summary>
/// Reads one frame. Returns false on a clean end of stream. Masked payloads are unmasked.
/// </summary>
internal static bool TryReadFrame(
Stream stream,
out WebSocketOpcode opcode,
out bool fin,
out byte[] payload
)
{
opcode = WebSocketOpcode.Close;
fin = true;
payload = [];
var header = new byte[2];
if (!TryReadExactly(stream, header))
{
return false;
}
fin = (header[0] & 0x80) != 0;
opcode = (WebSocketOpcode)(header[0] & 0x0F);
var masked = (header[1] & 0x80) != 0;
long length = header[1] & 0x7F;
if (length == 126)
{
var extended = new byte[2];
if (!TryReadExactly(stream, extended))
{
return false;
}
length = BinaryPrimitives.ReadUInt16BigEndian(extended);
}
else if (length == 127)
{
var extended = new byte[8];
if (!TryReadExactly(stream, extended))
{
return false;
}
length = (long)BinaryPrimitives.ReadUInt64BigEndian(extended);
}
if (length > MaxPayloadBytes)
{
return false; // защита от злонамеренной длины — соединение закроется
}
var maskKey = new byte[4];
if (masked && !TryReadExactly(stream, maskKey))
{
return false;
}
payload = new byte[length];
if (!TryReadExactly(stream, payload))
{
return false;
}
if (masked)
{
for (var i = 0; i < payload.Length; i++)
{
payload[i] ^= maskKey[i % 4];
}
}
return true;
}
/// <summary>Upper bound for a single frame payload accepted by the server.</summary>
internal const int MaxPayloadBytes = 16 * 1024 * 1024;
private static bool TryReadExactly(Stream stream, byte[] buffer)
{
var offset = 0;
while (offset < buffer.Length)
{
var read = stream.Read(buffer, offset, buffer.Length - offset);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
}
+372
View File
@@ -0,0 +1,372 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using MrGameEng.Core;
namespace MrGameEng.Net;
/// <summary>
/// A dependency-free WebSocket server (RFC 6455 over <see cref="TcpListener"/>) for
/// dedicated servers. Accepting and reading happen on background tasks; the simulation
/// drains new connections with <see cref="TryAcceptConnection"/> and reads messages by
/// polling each connection — nothing here touches the ECS world from another thread.
/// Binary messages only; incoming pings are answered automatically and the server itself
/// heartbeats each connection, closing any that goes silent past <see cref="IdleTimeout"/>
/// (detects half-open TCP — a peer that vanished without a close frame). A reassembled
/// message is capped at <see cref="MaxMessageBytes"/> so a peer can't exhaust memory.
/// </summary>
public sealed class WebSocketServer : IDisposable
{
/// <summary>Largest reassembled (possibly fragmented) message accepted from a peer.</summary>
public const int MaxMessageBytes = WebSocketProtocol.MaxPayloadBytes;
/// <summary>The port the server listens on.</summary>
public int Port { get; }
/// <summary>How often the server pings each connection to keep it alive and probe liveness.</summary>
public TimeSpan HeartbeatInterval { get; }
/// <summary>A connection with no traffic for longer than this is considered dead and closed.</summary>
public TimeSpan IdleTimeout { get; }
/// <summary>Snapshot of currently open connections.</summary>
public IReadOnlyList<INetConnection> Connections
{
get
{
lock (_connections)
{
return _connections.Where(c => c.IsOpen).Cast<INetConnection>().ToArray();
}
}
}
private readonly TcpListener _listener;
private readonly List<ServerConnection> _connections = [];
private readonly ConcurrentQueue<ServerConnection> _accepted = new();
private readonly CancellationTokenSource _shutdown = new();
private int _nextConnectionId;
private bool _started;
/// <summary>
/// Creates a server for <paramref name="port"/> on all interfaces. Call <see cref="Start"/>
/// to listen. <paramref name="heartbeatInterval"/> (default 10 s) sets how often each
/// connection is pinged; <paramref name="idleTimeout"/> (default 30 s) how long a silent
/// connection lives before it's dropped as dead. The timeout must exceed the interval so a
/// healthy peer's pong lands before it's judged idle.
/// </summary>
public WebSocketServer(
int port,
TimeSpan? heartbeatInterval = null,
TimeSpan? idleTimeout = null
)
{
Port = port;
HeartbeatInterval = heartbeatInterval ?? TimeSpan.FromSeconds(10);
IdleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
_listener = new TcpListener(IPAddress.Any, port);
}
/// <summary>Starts listening and accepting connections in the background.</summary>
public void Start()
{
if (_started)
{
return;
}
_started = true;
_listener.Start();
Task.Run(AcceptLoop);
Task.Run(HeartbeatLoop);
Log.Info($"WebSocketServer listening on port {Port}");
}
/// <summary>Dequeues a connection that completed its handshake since the last call.</summary>
public bool TryAcceptConnection(out INetConnection connection)
{
if (_accepted.TryDequeue(out var accepted))
{
connection = accepted;
return true;
}
connection = null!;
return false;
}
/// <summary>Stops listening and closes every connection.</summary>
public void Dispose()
{
_shutdown.Cancel();
_listener.Stop();
lock (_connections)
{
foreach (var connection in _connections)
{
connection.Close();
}
_connections.Clear();
}
}
private async Task AcceptLoop()
{
while (!_shutdown.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_shutdown.Token);
}
catch (Exception) when (_shutdown.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
Log.Warning($"WebSocketServer accept failed: {exception.Message}");
continue;
}
_ = Task.Run(() => Handshake(client));
}
}
// Пингует живые соединения и закрывает те, что молчат дольше IdleTimeout (мёртвый peer
// не отвечает pong'ом — его активность не обновляется и он отваливается по таймауту).
private async Task HeartbeatLoop()
{
while (!_shutdown.IsCancellationRequested)
{
try
{
await Task.Delay(HeartbeatInterval, _shutdown.Token);
}
catch (OperationCanceledException)
{
return;
}
ServerConnection[] snapshot;
lock (_connections)
{
snapshot = _connections.ToArray();
}
var now = DateTime.UtcNow;
foreach (var connection in snapshot)
{
if (!connection.IsOpen)
{
continue;
}
if (now - connection.LastActivityUtc > IdleTimeout)
{
Log.Info($"WebSocketServer: connection #{connection.Id} timed out (idle)");
connection.Close();
}
else
{
connection.SendPing();
}
}
}
}
private void Handshake(TcpClient client)
{
try
{
client.NoDelay = true;
var stream = client.GetStream();
if (!WebSocketProtocol.TryReadHandshakeKey(stream, out var key))
{
client.Dispose();
return;
}
WebSocketProtocol.WriteHandshakeResponse(stream, key);
var connection = new ServerConnection(
Interlocked.Increment(ref _nextConnectionId),
client
);
lock (_connections)
{
_connections.RemoveAll(c => !c.IsOpen);
_connections.Add(connection);
}
_accepted.Enqueue(connection);
connection.StartReceiveLoop();
Log.Info($"WebSocketServer: connection #{connection.Id} accepted");
}
catch (Exception exception)
{
Log.Warning($"WebSocketServer handshake failed: {exception.Message}");
client.Dispose();
}
}
private sealed class ServerConnection : INetConnection
{
public int Id { get; }
public bool IsOpen => !_closed;
/// <summary>UTC of the last frame received from the peer — drives idle-timeout detection.</summary>
public DateTime LastActivityUtc =>
new(Volatile.Read(ref _lastActivityTicks), DateTimeKind.Utc);
private readonly TcpClient _client;
private readonly NetworkStream _stream;
private readonly ConcurrentQueue<byte[]> _inbox = new();
private readonly object _sendLock = new();
private volatile bool _closed;
private long _lastActivityTicks;
internal ServerConnection(int id, TcpClient client)
{
Id = id;
_client = client;
_stream = client.GetStream();
_lastActivityTicks = DateTime.UtcNow.Ticks;
}
internal void StartReceiveLoop() => Task.Run(ReceiveLoop);
/// <summary>Sends a heartbeat ping; a live peer answers with a pong, refreshing activity.</summary>
internal void SendPing() => SendControl(WebSocketOpcode.Ping, []);
public void Send(ReadOnlySpan<byte> message)
{
if (_closed)
{
return;
}
var frame = WebSocketProtocol.EncodeFrame(message, WebSocketOpcode.Binary);
try
{
lock (_sendLock)
{
_stream.Write(frame, 0, frame.Length);
}
}
catch (Exception)
{
Close();
}
}
private void SendControl(WebSocketOpcode opcode, ReadOnlySpan<byte> payload)
{
if (_closed)
{
return;
}
var frame = WebSocketProtocol.EncodeFrame(payload, opcode);
try
{
lock (_sendLock)
{
_stream.Write(frame, 0, frame.Length);
}
}
catch (Exception)
{
Close();
}
}
public bool TryReceive(out byte[] message) => _inbox.TryDequeue(out message!);
public void Close()
{
if (_closed)
{
return;
}
_closed = true;
try
{
_client.Dispose();
}
catch (Exception)
{
// соединение уже мертво — закрытие не должно бросать
}
}
private void ReceiveLoop()
{
var pending = new List<byte>();
var pendingOpcode = WebSocketOpcode.Binary;
try
{
while (!_closed)
{
if (
!WebSocketProtocol.TryReadFrame(
_stream,
out var opcode,
out var fin,
out var payload
)
)
{
break;
}
// Любой кадр (включая pong) — признак жизни: сбрасываем счётчик простоя.
Volatile.Write(ref _lastActivityTicks, DateTime.UtcNow.Ticks);
switch (opcode)
{
case WebSocketOpcode.Ping:
SendControl(WebSocketOpcode.Pong, payload);
continue;
case WebSocketOpcode.Pong:
continue;
case WebSocketOpcode.Close:
SendControl(WebSocketOpcode.Close, []);
return;
}
if (opcode != WebSocketOpcode.Continuation)
{
pendingOpcode = opcode;
pending.Clear();
}
if (pending.Count + payload.Length > MaxMessageBytes)
{
Log.Warning(
$"WebSocketServer: connection #{Id} exceeded {MaxMessageBytes}-byte "
+ "message cap — closing"
);
return; // finally закроет соединение
}
pending.AddRange(payload);
if (fin && pendingOpcode == WebSocketOpcode.Binary)
{
_inbox.Enqueue(pending.ToArray());
pending.Clear();
}
}
}
catch (Exception)
{
// обрыв соединения — штатный путь завершения цикла
}
finally
{
Close();
}
}
}
}
@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+50
View File
@@ -0,0 +1,50 @@
namespace MrGameEng.AI;
/// <summary>
/// A small typed key/value store for an agent's working memory: perceived facts, a current target, a
/// cached path goal — whatever the considerations and actions need to share without being threaded
/// through method signatures. Keys are case-sensitive strings; values are stored boxed, so the
/// blackboard is a convenience for cold paths (perception, planning), not the per-frame hot loop.
/// </summary>
public sealed class Blackboard
{
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
/// <summary>The number of keys currently stored.</summary>
public int Count => _values.Count;
/// <summary>Stores <paramref name="value"/> under <paramref name="key"/>, replacing any existing entry.</summary>
public void Set<T>(string key, T value) => _values[key] = value;
/// <summary>
/// Reads the value under <paramref name="key"/> as <typeparamref name="T"/>. Returns <c>false</c> when
/// the key is missing or holds a value of a different type.
/// </summary>
public bool TryGet<T>(string key, out T value)
{
if (_values.TryGetValue(key, out var stored) && stored is T typed)
{
value = typed;
return true;
}
value = default!;
return false;
}
/// <summary>
/// Reads the value under <paramref name="key"/>, or returns <paramref name="fallback"/> when the key is
/// missing or holds a different type.
/// </summary>
public T GetOrDefault<T>(string key, T fallback = default!) =>
TryGet<T>(key, out var value) ? value : fallback;
/// <summary>True when <paramref name="key"/> has a value (of any type).</summary>
public bool Has(string key) => _values.ContainsKey(key);
/// <summary>Removes <paramref name="key"/>. Returns true when it was present.</summary>
public bool Remove(string key) => _values.Remove(key);
/// <summary>Drops every stored value.</summary>
public void Clear() => _values.Clear();
}
@@ -0,0 +1,56 @@
namespace MrGameEng.AI;
/// <summary>
/// One input to a utility decision. It reads a raw value from the agent's context, normalizes it to
/// <c>[0,1]</c> against an expected range, and shapes it through a <see cref="ResponseCurve"/> into a
/// utility score. Considerations are stateless and reusable: the context carries everything that
/// varies. <typeparamref name="TContext"/> is whatever the game passes in — a struct of perceived
/// values, an entity handle, a blackboard — the AI module never owns it.
/// </summary>
public sealed class Consideration<TContext>
{
private readonly Func<TContext, float> _input;
private readonly float _min;
private readonly float _inverseSpan;
private readonly ResponseCurve _curve;
/// <summary>
/// Creates a consideration named <paramref name="name"/> that reads <paramref name="input"/> from the
/// context, normalizes it from <c>[<paramref name="min"/>, <paramref name="max"/>]</c> to <c>[0,1]</c>
/// (values outside the range clamp to the ends), then applies <paramref name="curve"/>.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="max"/> is not greater than <paramref name="min"/>.</exception>
public Consideration(
string name,
Func<TContext, float> input,
float min = 0f,
float max = 1f,
ResponseCurve? curve = null
)
{
if (max <= min)
{
throw new ArgumentException(
$"max ({max}) must be greater than min ({min}).",
nameof(max)
);
}
Name = name;
_input = input ?? throw new ArgumentNullException(nameof(input));
_min = min;
_inverseSpan = 1f / (max - min);
// default(ResponseCurve) has slope 0 (always 0), so omitting the curve means the identity.
_curve = curve ?? ResponseCurve.Identity;
}
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
public string Name { get; }
/// <summary>Reads the context and returns this consideration's utility in <c>[0,1]</c>.</summary>
public float Score(TContext context)
{
var normalized = Math.Clamp((_input(context) - _min) * _inverseSpan, 0f, 1f);
return _curve.Evaluate(normalized);
}
}
@@ -0,0 +1,115 @@
namespace MrGameEng.AI;
/// <summary>Shape of a <see cref="ResponseCurve"/> mapping a normalized input to a utility.</summary>
public enum CurveType
{
/// <summary>Straight line: <c>y = slope·(x xShift) + yShift</c>.</summary>
Linear,
/// <summary>Power curve: <c>y = slope·(x xShift)^exponent + yShift</c>; the exponent eases in/out.</summary>
Polynomial,
/// <summary>S-shaped logistic centred on <c>xShift</c>; <c>exponent</c> is the steepness.</summary>
Logistic,
/// <summary>Hermite smoothstep over <c>[xShift, xShift + 1/slope]</c>; flat ends, smooth middle.</summary>
SmoothStep,
}
/// <summary>
/// Maps a normalized input in <c>[0,1]</c> to a utility in <c>[0,1]</c> through one of a few
/// shapes. The input is clamped before evaluation and the output is clamped after, so a curve is
/// always safe to feed a raw normalized <see cref="Consideration{TContext}"/> value. Curves are
/// immutable value types — build them once and reuse them across evaluations.
/// </summary>
public readonly struct ResponseCurve
{
/// <summary>The shape applied by <see cref="Evaluate"/>.</summary>
public CurveType Type { get; }
/// <summary>Vertical scale / steepness (the <c>m</c> term). See <see cref="CurveType"/> per shape.</summary>
public float Slope { get; }
/// <summary>Power for <see cref="CurveType.Polynomial"/> and steepness for <see cref="CurveType.Logistic"/>.</summary>
public float Exponent { get; }
/// <summary>Horizontal shift of the curve (the <c>c</c> term): the input value mapped to the origin.</summary>
public float XShift { get; }
/// <summary>Vertical shift of the curve (the <c>b</c> term) added after scaling.</summary>
public float YShift { get; }
/// <summary>
/// Builds a curve from raw parameters. Prefer the named factories
/// (<see cref="Linear"/>, <see cref="Polynomial"/>, <see cref="Logistic"/>, <see cref="SmoothStep"/>)
/// which document the meaning of each term for their shape.
/// </summary>
public ResponseCurve(
CurveType type,
float slope = 1f,
float exponent = 1f,
float xShift = 0f,
float yShift = 0f
)
{
Type = type;
Slope = slope;
Exponent = exponent;
XShift = xShift;
YShift = yShift;
}
/// <summary>The identity curve: <c>y = x</c>. The default when a consideration needs no shaping.</summary>
public static ResponseCurve Identity => new(CurveType.Linear);
/// <summary>Straight line <c>y = slope·(x xShift) + yShift</c>. A negative slope inverts the input.</summary>
public static ResponseCurve Linear(float slope = 1f, float xShift = 0f, float yShift = 0f) =>
new(CurveType.Linear, slope, 1f, xShift, yShift);
/// <summary>
/// Power curve <c>y = slope·(x xShift)^exponent + yShift</c>. An exponent above 1 eases in
/// (slow start), below 1 eases out (fast start). Quadratic is <c>exponent = 2</c>.
/// </summary>
public static ResponseCurve Polynomial(
float exponent,
float slope = 1f,
float xShift = 0f,
float yShift = 0f
) => new(CurveType.Polynomial, slope, exponent, xShift, yShift);
/// <summary>
/// Logistic S-curve centred on <paramref name="midpoint"/>; <paramref name="steepness"/> controls how
/// sharp the transition is (≈10 gives a soft threshold, larger is more switch-like).
/// </summary>
public static ResponseCurve Logistic(float steepness = 10f, float midpoint = 0.5f) =>
new(CurveType.Logistic, 1f, steepness, midpoint);
/// <summary>
/// Hermite smoothstep rising from 0 to 1 over <c>[xShift, xShift + 1/slope]</c>: flat below the
/// start, flat above the end, smooth in between. Default rises across the whole <c>[0,1]</c> range.
/// </summary>
public static ResponseCurve SmoothStep(float slope = 1f, float xShift = 0f) =>
new(CurveType.SmoothStep, slope, 1f, xShift);
/// <summary>Evaluates the curve. <paramref name="x"/> is clamped to <c>[0,1]</c>; the result is clamped to <c>[0,1]</c>.</summary>
public float Evaluate(float x)
{
x = Math.Clamp(x, 0f, 1f);
var y = Type switch
{
CurveType.Linear => Slope * (x - XShift) + YShift,
CurveType.Polynomial => Slope * MathF.Pow(x - XShift, Exponent) + YShift,
CurveType.Logistic => 1f / (1f + MathF.Exp(-Exponent * (x - XShift))) * Slope + YShift,
CurveType.SmoothStep => SmoothStepValue(x),
_ => x,
};
return Math.Clamp(y, 0f, 1f);
}
private float SmoothStepValue(float x)
{
var t = Math.Clamp((x - XShift) * Slope, 0f, 1f);
return t * t * (3f - 2f * t) + YShift;
}
}

Some files were not shown because too many files have changed in this diff Show More