diff --git a/bin/app/data/config/world.toml b/bin/app/data/config/world.toml index 9c8c937..b5ed7e5 100644 --- a/bin/app/data/config/world.toml +++ b/bin/app/data/config/world.toml @@ -17,6 +17,13 @@ player_buffer_width_tiles = 20 contest_zone_width_tiles = 60 enemy_buffer_width_tiles = 20 +[scroll] +# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the +# contest zone, with a linear ramp of the given width straddling each boundary. +pan_speed_slow_tiles_per_second = 8.0 +pan_speed_fast_tiles_per_second = 24.0 +pan_ramp_band_width_tiles = 16 + [expansion] columns_per_expansion_tiles = 10 # x = expansions already purchased; ~1 per cycle mid-game, decelerating diff --git a/bin/test/data/config/world.toml b/bin/test/data/config/world.toml index 1bf2e91..4ec5bb6 100644 --- a/bin/test/data/config/world.toml +++ b/bin/test/data/config/world.toml @@ -17,6 +17,11 @@ player_buffer_width_tiles = 10 contest_zone_width_tiles = 30 enemy_buffer_width_tiles = 15 +[scroll] +pan_speed_slow_tiles_per_second = 8.0 +pan_speed_fast_tiles_per_second = 24.0 +pan_ramp_band_width_tiles = 16 + [expansion] columns_per_expansion_tiles = 10 cost_building_blocks_formula = "400 * 2^x" diff --git a/docs/requirements.md b/docs/requirements.md index b28d900..9ae545e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -4,7 +4,7 @@ Config files use the TOML format. The following config files drive game parameters: -- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, and artifact win count. +- **world.toml** — world dimensions, region widths, expansion amounts, building refund percentage, wave timing, boss wave timing, belt speed, starting building blocks, departure interval, ship orbit factor, rally orbit radius, scrap-per-threat conversion, combat target-selection parameters (target score formula, overclaim penalty formula, target hysteresis), artifact chance formula, artifact win count, and view pan speeds (slow and fast horizontal pan speed and pan ramp band width). - **buildings.toml** — building block cost and construction time per building type. - **recipes.toml** — crafting recipes: inputs, outputs, quantities, durations, and reprocessing plant probabilities. Assembler recipe entries may optionally define `unlock_at_station_level` (integer): -1 means the recipe is explicitly unlocked at game start; a value ≥ 0 means the recipe starts locked and a schematic for it can be awarded via defence station destruction (see REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP). An assembler recipe schematic entry may also define an optional `unlock_requires` list of prerequisite schematic ids (REQ-LOCK-PREREQ). - **ships.toml** — per schematic: a human-readable display name (used in the UI), hull stats (HP, max linear speed, sensor range, main acceleration, maneuvering acceleration, angular acceleration, max rotation speed) as plain values, required build materials, the station level at which the schematic becomes available for unlock (`unlock_at_station_level`; -1 means the player starts with the schematic already unlocked), an optional `unlock_requires` prerequisite list (REQ-LOCK-PREREQ), a layout grid defining the ship's module slots, and a `default_modules` list used for enemy wave ships (see REQ-WAV-DEFAULT-MODULES). @@ -403,7 +403,13 @@ The screen is divided into two columns: a main column (75% width) containing the ### Game World -- REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right). +- REQ-UI-SCROLL: The player scrolls the view horizontally across the scrollable area by pressing A (scroll left) and D (scroll right). The pan speed is not constant; it varies with the view's position per REQ-UI-SCROLL-SPEED. +- REQ-UI-SCROLL-SPEED: The horizontal pan speed varies with position so the player crosses the empty middle of the world quickly while retaining fine control near the asteroid and near the front line. Two pan speeds are read from `world.toml [scroll]`: `pan_speed_slow_tiles_per_second` (the base speed, used while the view is over the asteroid and player buffer zone) and `pan_speed_fast_tiles_per_second` (the faster speed, used while the view is over the contest zone). Both are expressed in tiles per second and apply equally to the A and D scroll directions. The current pan speed is a function of the view's horizontal center X (REQ-GW-REGIONS defines the regions): + - **Flat regions:** while the view center is left of the contest zone (over the asteroid or player buffer zone) and outside any ramp band, the pan speed is the slow speed; while the view center is inside the contest zone and outside any ramp band, the pan speed is the fast speed. + - **Ramp bands:** a transition ramp band of width `world.toml [scroll].pan_ramp_band_width_tiles` tiles straddles each contest-zone boundary (the player defence stations on the left, the enemy defence stations on the right), centered on the boundary with half the band width on each side. While the view center is within a ramp band, the pan speed is linearly interpolated between the slow speed at the band's outer (non-contest-zone) edge and the fast speed at the band's inner (contest-zone) edge, by the view center's fractional position across the band. This produces smooth speed changes when entering and exiting the fast contest-zone range rather than an abrupt jump. + - **Narrow contest zone:** should the two ramp bands overlap (a contest zone narrower than the band width), each ramp is clamped at the contest-zone center so the bands do not cross; the fast plateau then reduces to a single point at the center and the peak speed there may be below the fast speed. + + Because the contest-zone boundaries shift as the scrollable area grows with each push (REQ-GW-PUSH-EXPAND, REQ-GW-SCROLL-LIMIT), the ramp bands are recomputed from the current contest-zone boundaries. This is a presentation-only concern and does not affect the simulation, consistent with REQ-UI-NO-ZOOM. - REQ-UI-CONSTRUCTION-PROGRESS: Construction sites display the building's glyph centered on the footprint (same as an operational building). Below the glyph — or centered on the footprint if the building has no glyph — a construction progress percentage is shown (integer, e.g. `42%`), increasing from 0% to 100% as construction completes. - REQ-UI-PORT-GLYPH: Every output port of every building is indicated by a directional glyph drawn on the port's tile. The glyph is a `>` rotated to face the port's exit direction (`>` for East, `^` for North, `<` for West, `v` for South). It is drawn at the midpoint between the tile center and the tile edge that the port exits through (i.e. halfway from center toward the exit edge). The indicator is rendered for all building states: operational buildings, construction sites, and the builder-mode ghost. Buildings with multiple output ports (e.g. splitters) show one indicator per port. - REQ-UI-HP-BARS: All entities with HP — the HQ, player and enemy defence stations, and player and enemy ships — render an HP bar below them. The bar is always visible regardless of current HP. The bar's filled portion represents the fraction of current HP to maximum HP. diff --git a/src/lib/config/ConfigLoader.cpp b/src/lib/config/ConfigLoader.cpp index a5adfff..e89c2ba 100644 --- a/src/lib/config/ConfigLoader.cpp +++ b/src/lib/config/ConfigLoader.cpp @@ -305,6 +305,10 @@ WorldConfig ConfigLoader::loadWorld(const std::string& path) cfg.artifacts.artifactChanceFormula = requireFormula(tbl["artifacts"]["artifact_chance_formula"], file, "artifacts.artifact_chance_formula"); cfg.artifacts.artifactWinCount = static_cast(requireInt(tbl["artifacts"]["artifact_win_count"], file, "artifacts.artifact_win_count")); + cfg.scroll.panSpeedSlow_tps = requireDouble(tbl["scroll"]["pan_speed_slow_tiles_per_second"], file, "scroll.pan_speed_slow_tiles_per_second"); + cfg.scroll.panSpeedFast_tps = requireDouble(tbl["scroll"]["pan_speed_fast_tiles_per_second"], file, "scroll.pan_speed_fast_tiles_per_second"); + cfg.scroll.panRampBandWidth_tiles = static_cast(requireInt(tbl["scroll"]["pan_ramp_band_width_tiles"], file, "scroll.pan_ramp_band_width_tiles")); + return cfg; } diff --git a/src/lib/config/WorldConfig.h b/src/lib/config/WorldConfig.h index 8e9f755..035505b 100644 --- a/src/lib/config/WorldConfig.h +++ b/src/lib/config/WorldConfig.h @@ -53,6 +53,14 @@ struct WorldArtifacts int artifactWinCount; }; +// View pan speed (REQ-UI-SCROLL-SPEED). Presentation-only; the simulation ignores these. +struct WorldScroll +{ + double panSpeedSlow_tps; // tiles/s, used outside the contest zone + double panSpeedFast_tps; // tiles/s, used inside the contest zone + int panRampBandWidth_tiles; // full width of the ramp band straddling each contest-zone boundary +}; + struct WorldConfig { int heightTiles; // REQ-GW-HEIGHT @@ -73,4 +81,5 @@ struct WorldConfig WorldWaves waves; WorldTargeting targeting; WorldArtifacts artifacts; + WorldScroll scroll; }; diff --git a/src/ui/GameWorldView.cpp b/src/ui/GameWorldView.cpp index 7e4bb87..c583796 100644 --- a/src/ui/GameWorldView.cpp +++ b/src/ui/GameWorldView.cpp @@ -272,7 +272,9 @@ void GameWorldView::onFrame() // Apply held scroll { - const float delta = kScrollSpeedTilesPerSec + // Pan speed depends on where the view is centered (REQ-UI-SCROLL-SPEED). + const float viewCenterX = m_scrollXTiles + viewportWidthTiles() / 2.0f; + const float delta = panSpeedTilesPerSecondAt(viewCenterX) * static_cast(elapsed) / 1000.0f; const float scrollBefore = m_scrollXTiles; if (m_scrollLeft) { m_scrollXTiles -= delta; } @@ -488,6 +490,39 @@ float GameWorldView::enemyStationRightEdge() const return rightX; } +namespace +{ + // Linearly blend from valueAt0 (for x <= x0) to valueAt1 (for x >= x1), clamped + // outside [x0, x1]. A zero- or negative-width band collapses to a hard step at x1. + float lerpClamped(float valueAt0, float valueAt1, float x0, float x1, float x) + { + if (x1 <= x0) { return x < x1 ? valueAt0 : valueAt1; } + const float t = std::max(0.0f, std::min(1.0f, (x - x0) / (x1 - x0))); + return valueAt0 + (valueAt1 - valueAt0) * t; + } +} + +float GameWorldView::panSpeedTilesPerSecondAt(float viewCenterXTiles) const +{ + // Slow near the asteroid/player buffer, fast across the contest zone, with a + // linear ramp straddling each contest-zone boundary (REQ-UI-SCROLL-SPEED). The + // contest zone spans from the player buffer's right edge to the enemy stations, + // the latter tracked live so the ramp follows the front line as it is pushed. + const float slow = static_cast(m_config->world.scroll.panSpeedSlow_tps); + const float fast = static_cast(m_config->world.scroll.panSpeedFast_tps); + const float half = static_cast(m_config->world.scroll.panRampBandWidth_tiles) / 2.0f; + const float leftEdge = static_cast(m_config->world.regions.playerBufferWidth_tiles); + const float rightEdge = enemyStationRightEdge(); + + // Rising ramp at the left boundary (slow -> fast) and falling ramp at the right + // boundary (fast -> slow); their minimum yields flat-slow outside, flat-fast in + // the middle, and — if the bands overlap in a narrow contest zone — a single peak + // below the fast speed where the two ramps cross. + const float leftRamp = lerpClamped(slow, fast, leftEdge - half, leftEdge + half, viewCenterXTiles); + const float rightRamp = lerpClamped(fast, slow, rightEdge - half, rightEdge + half, viewCenterXTiles); + return std::min(leftRamp, rightRamp); +} + void GameWorldView::clampScroll() { const float leftBound = asteroidLeftEdge(); diff --git a/src/ui/GameWorldView.h b/src/ui/GameWorldView.h index abf0bf8..b4bf895 100644 --- a/src/ui/GameWorldView.h +++ b/src/ui/GameWorldView.h @@ -140,6 +140,8 @@ private: float asteroidLeftEdge() const; float enemyStationRightEdge() const; + // Horizontal pan speed at a given view-center X, in tiles/s (REQ-UI-SCROLL-SPEED). + float panSpeedTilesPerSecondAt(float viewCenterXTiles) const; void clampScroll(); bool isValidPlacement(BuildingType type, QPoint anchor, Rotation rot) const; @@ -179,7 +181,6 @@ private: // Beam lifetime in game ticks so beams freeze with the simulation when // paused or slowed, instead of fading on wall-clock time (REQ-SHP-FIRING-BEAM). static constexpr Tick kBeamLifetimeTicks = secondsToTicks(0.3); - static constexpr float kScrollSpeedTilesPerSec = 10.0f; Simulation* m_sim; const GameConfig* m_config;