using Raylib_cs; using Sand.Core; using System.Numerics; using System.Diagnostics; namespace Sand.App; internal static partial class SandApp { private static void Update(AppState state) { var updateStart = Stopwatch.GetTimestamp(); var dt = Raylib.GetFrameTime(); var mouse = Raylib.GetMousePosition(); var worldRect = new Rectangle(SidebarWidth, 0, state.SimWidth * ParticleSize, state.SimHeight * ParticleSize); var overSidebar = mouse.X < SidebarWidth; var settingsRect = GetSettingsRect(state); HandleHotkeys(state); HandleMouseWheel(state, mouse, overSidebar); HandleWorldPainting(state, mouse, worldRect, overSidebar, settingsRect); HandleSidebarClick(state, mouse); StepSimulation(state, dt, mouse); state.PreviousMouse = mouse; state.LastUpdateTimeMicroseconds = ToMicroseconds(updateStart, Stopwatch.GetTimestamp()); } private static void HandleHotkeys(AppState state) { if (Raylib.IsKeyPressed(KeyboardKey.Space)) { state.Settings.PauseSim = !state.Settings.PauseSim; } if (Raylib.IsKeyPressed(KeyboardKey.C)) { state.Simulation.Clear(); } if (Raylib.IsKeyPressed(KeyboardKey.Z)) { state.ZoomEnabled = !state.ZoomEnabled; } if (Raylib.IsKeyPressed(KeyboardKey.F3)) { state.Settings.EnableDebug = !state.Settings.EnableDebug; } if (Raylib.IsKeyPressed(KeyboardKey.Tab)) { state.SettingsVisible = !state.SettingsVisible; } } private static void HandleMouseWheel(AppState state, Vector2 mouse, bool overSidebar) { var wheel = Raylib.GetMouseWheelMove(); if (MathF.Abs(wheel) <= 0f) { return; } var settingsRect = GetSettingsRect(state); if (state.SettingsVisible && Raylib.CheckCollisionPointRec(mouse, settingsRect)) { var maxOffset = Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(settingsRect)); state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset - (int)wheel, 0, maxOffset); return; } var overCategoryList = overSidebar && mouse.Y >= 60 && mouse.Y < 240; if (overCategoryList) { var maxOffset = Math.Max(0, state.Categories.Count - GetVisibleCategoryRows()); state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset - (int)wheel, 0, maxOffset); return; } var overParticleList = overSidebar && mouse.Y >= 240 && mouse.Y <= Raylib.GetScreenHeight() - 164; if (overParticleList) { var visibleRows = GetVisibleParticleRows(); var maxOffset = Math.Max(0, state.Categories[state.CurrentCategory].Count - visibleRows); state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset - (int)wheel, 0, maxOffset); return; } state.BrushRadius = Math.Clamp(state.BrushRadius + (int)wheel, 1, 16); } private static void HandleWorldPainting(AppState state, Vector2 mouse, Rectangle worldRect, bool overSidebar, Rectangle settingsRect) { var canPaintWorld = !overSidebar && (!state.SettingsVisible || !Raylib.CheckCollisionPointRec(mouse, settingsRect)) && Raylib.CheckCollisionPointRec(mouse, worldRect); state.PendingWorldAction = default; if (Raylib.IsMouseButtonDown(MouseButton.Left) && canPaintWorld) { var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize; var simY = (int)(mouse.Y / ParticleSize) * ParticleSize; state.PendingWorldAction = new PendingWorldAction(true, false, simX, simY, mouse); if (Raylib.IsMouseButtonPressed(MouseButton.Left)) { ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true); } } if (Raylib.IsMouseButtonDown(MouseButton.Right) && canPaintWorld) { var simX = (int)((mouse.X - SidebarWidth) / ParticleSize) * ParticleSize; var simY = (int)(mouse.Y / ParticleSize) * ParticleSize; state.PendingWorldAction = new PendingWorldAction(true, true, simX, simY, mouse); if (Raylib.IsMouseButtonPressed(MouseButton.Right)) { ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: false); } } } private static void ApplyWorldAction(AppState state, PendingWorldAction action, bool updateWindDirection, bool continuous = false) { if (!action.Active) { return; } if (action.IsErase) { state.Simulation.ClearParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, GetContinuousEraseCount(state), state.Simulation.Frame); return; } if (updateWindDirection && state.CurrentParticle is "wind" or "air") { var drag = action.Mouse - state.PreviousMouse; if (drag.LengthSquared() > 0.25f) { state.LastWindDirection = Vector2.Normalize(drag); } } switch (state.CurrentParticle) { case "wind": state.Simulation.ApplyWindBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y); break; case "air": state.Simulation.ApplyAirBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, state.LastWindDirection.X, state.LastWindDirection.Y); break; case "gravity_well": state.Simulation.ApplyGravityBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3.5f); break; case "repulsor": state.Simulation.ApplyRepulsorBrushAtPixel(action.SimX, action.SimY, state.BrushRadius, 3f); break; default: if (continuous) { var pourCount = GetContinuousPaintCount(state); state.Simulation.CreateParticlePourAtPixel(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle, pourCount, state.Simulation.Frame); } else { state.Simulation.CreateParticleCircle(action.SimX, action.SimY, state.BrushRadius, state.CurrentParticle); } break; } } private static void HandleSidebarClick(AppState state, Vector2 mouse) { if (!Raylib.IsMouseButtonPressed(MouseButton.Left)) { return; } HandleSidebar(state, mouse); if (state.SettingsVisible) { HandleSettingsPanel(state, GetSettingsRect(state), mouse); } } private static void StepSimulation(AppState state, float dt, Vector2 mouse) { var simStart = Stopwatch.GetTimestamp(); state.CategoryScrollOffset = Math.Clamp(state.CategoryScrollOffset, 0, Math.Max(0, state.Categories.Count - GetVisibleCategoryRows())); state.ParticleScrollOffset = Math.Clamp(state.ParticleScrollOffset, 0, Math.Max(0, state.Categories[state.CurrentCategory].Count - GetVisibleParticleRows())); state.SettingsScrollOffset = Math.Clamp(state.SettingsScrollOffset, 0, Math.Max(0, state.SettingItems.Length - GetVisibleSettingsRows(GetSettingsRect(state)))); state.LastSimulationStepCount = 0; if (state.Settings.PauseSim) { if (state.PendingWorldAction.Active) { ApplyWorldAction(state, state.PendingWorldAction, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state)); } state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp()); return; } state.SimulationAccumulator += dt; var fixedStep = 1f / MathF.Max(1f, state.Settings.SimulationStepsPerSecond); var overload = IsInteractiveOverloaded(state, fixedStep); var maxSteps = overload ? 2 : 4; while (state.SimulationAccumulator >= fixedStep && maxSteps-- > 0) { if (state.PendingWorldAction.Active) { ApplyWorldAction(state, state.PendingWorldAction with { Mouse = mouse }, updateWindDirection: true, continuous: ShouldUseContinuousPaint(state)); } state.Simulation.Step(fixedStep * state.Settings.TimeScale); state.LastSimulationStepCount++; state.SimulationAccumulator -= fixedStep; } if (state.SimulationAccumulator > fixedStep * (overload ? 1.5f : 3f)) { state.SimulationAccumulator = fixedStep; } state.LastSimulationLoopTimeMicroseconds = ToMicroseconds(simStart, Stopwatch.GetTimestamp()); } private static Rectangle GetSettingsRect(AppState state) { return new Rectangle( Raylib.GetScreenWidth() - SettingsWidth - 12, 100, SettingsWidth, MathF.Min(32 + (state.SettingItems.Length * 34), Raylib.GetScreenHeight() - 120)); } private static int GetVisibleSettingsRows(Rectangle panelRect) { return Math.Max(1, (int)((panelRect.Height - 68f) / 34f)); } private static bool ShouldUseContinuousPaint(AppState state) { if (state.CurrentParticle is "wind" or "air" or "gravity_well" or "repulsor") { return true; } var typeId = state.Library.GetTypeId(state.CurrentParticle); var particle = state.Library.GetDefinition(typeId); return particle.Kind == ParticleKind.Gas || particle.Kind == ParticleKind.Liquid || (particle.Kind == ParticleKind.Solid && !particle.IsStatic); } private static int GetContinuousPaintCount(AppState state) { var typeId = state.Library.GetTypeId(state.CurrentParticle); var particle = state.Library.GetDefinition(typeId); var baseCount = GetContinuousPaintCount(particle, state.BrushRadius); return ApplyInteractiveLoadScaling(state, baseCount, particle.Kind == ParticleKind.Gas); } internal static int GetContinuousPaintCount(ParticleDef particle, int brushRadius) { return particle.Kind switch { ParticleKind.Gas => Math.Clamp(brushRadius + 1, 1, 8), ParticleKind.Liquid => Math.Clamp((brushRadius * 2) + 1, 1, 24), ParticleKind.Solid when !particle.IsStatic => Math.Clamp((brushRadius * 2) + 1, 1, 24), _ => 0, }; } private static int GetContinuousEraseCount(AppState state) { var baseCount = Math.Clamp((state.BrushRadius * 2) + 2, 1, 18); return ApplyInteractiveLoadScaling(state, baseCount, gasBiased: true); } private static bool IsInteractiveOverloaded(AppState state, float fixedStep) { var stepBudgetMicros = fixedStep * 1_000_000f; if (state.LastSimulationStepCount >= 4) { return true; } if (state.LastSimulationLoopTimeMicroseconds > stepBudgetMicros * 2.25f) { return true; } return state.LastAppFrameTimeMicroseconds > 40_000; } private static int ApplyInteractiveLoadScaling(AppState state, int baseCount, bool gasBiased) { if (baseCount <= 1) { return baseCount; } var severeOverload = state.LastSimulationStepCount >= 4 || state.LastSimulationLoopTimeMicroseconds > 45_000 || state.LastAppFrameTimeMicroseconds > 60_000; if (severeOverload) { return gasBiased ? Math.Max(1, baseCount / 4) : Math.Max(1, baseCount / 3); } var overload = state.LastSimulationStepCount >= 2 || state.LastSimulationLoopTimeMicroseconds > 25_000 || state.LastAppFrameTimeMicroseconds > 33_000; if (overload) { return gasBiased ? Math.Max(1, baseCount / 2) : Math.Max(1, (baseCount * 2) / 3); } return baseCount; } }