saga_map 2.1.0 copy "saga_map: ^2.1.0" to clipboard
saga_map: ^2.1.0 copied to clipboard

A Flutter package for building responsive world-map level progression UIs with custom host-provided assets.

example/lib/main.dart

// SagaMap — full feature demo.
//
// Everything the package can do lives in this one file, wired to a control
// panel so each feature can be switched on and off while the map runs. Tap a
// level node to walk the character there; completing a level unlocks the next
// one and lights up the path behind you.
//
// Run with:
//   cd example && flutter run

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:saga_map/saga_map.dart';

void main() => runApp(const SagaMapDemoApp());

/// Pixels of path each level occupies along the scroll axis.
///
/// Orientation-independent on purpose: the same value drives chunk width in a
/// horizontal map and chunk height in a vertical one, so node density looks the
/// same either way.
const double _pixelsPerLevel = 96;

/// Levels generated per chunk. This also fixes `chunkSpanNormalized`, which
/// must equal `stepHeight * levelsPerChunk` — see
/// [SagaMapConfig.spanForLevelCount].
const int _levelsPerChunk = 10;

/// The geometry the whole demo generates against, when it is not overriding
/// the biome ids. See `_SagaMapDemoState._mapConfig`.
const SagaMapConfig _baseMapConfig = SagaMapConfig.defaultConfig;

/// Where the demo's one gate sits. Closed, it holds the character on the near
/// side until the host decides to open it.
const int _gateLevel = 12;

/// The walk cycle bundled with this example: six 48x64 frames side by side,
/// which is the default [SagaSpriteLayout.horizontal].
const SagaSpriteSheet _heroSheet = SagaSpriteSheet(
  frameWidth: 48,
  frameHeight: 64,
  frameCount: 6,
);

/// A 2.0.0 showcase: the reward rules are injected, not baked in.
///
/// Every fifth level a player sees is a boss here. Ids are zero-based, so that
/// is `% 5 == 4` — the same shape as the corrected built-in `isBossLevel`,
/// which is `% 15 == 14`.
bool _frequentBossRule(int levelId) => levelId >= 0 && levelId % 5 == 4;

/// The table those bosses roll against. Nothing here comes from the package.
const List<LootTableEntry> _demoLootTable = <LootTableEntry>[
  LootTableEntry(
    itemId: 'demo_acorn',
    itemName: 'Lucky Acorn',
    rarity: InventoryRarity.common,
    weight: 70,
  ),
  LootTableEntry(
    itemId: 'demo_lantern',
    itemName: "Wanderer's Lantern",
    rarity: InventoryRarity.rare,
    weight: 25,
  ),
  LootTableEntry(
    itemId: 'demo_sunspire',
    itemName: 'Sunspire Crown',
    rarity: InventoryRarity.legendary,
    weight: 5,
  ),
];

/// The demo's own biome ids (2.0.0).
///
/// Before `SagaMapConfig.biomeIds`, the generator read the `const` global
/// `kSagaBiomeIds` and a host with its own realms had no way in. These five are
/// nothing the package has heard of; it cycles them all the same.
const List<String> _demoRealms = <String>[
  'sunspire',
  'drownlands',
  'ashreach',
  'verdant',
  'gloamvale',
];

/// A 2.1.0 showcase: bad-luck protection on the boss drops.
///
/// Two drops below rare in a row, and the next boss is guaranteed rare or
/// better. Deliberately low, so the rule shows within a few bosses.
const SagaPityRule _demoPityRule = SagaPityRule(threshold: 2);

/// Where the demo keeps its pity counter: host-owned save data in
/// `SagaProgress.extra`. The package never reads it; the rule moves it.
const String _pityKey = 'demo.pity';

/// The demo's one alternate replay mode (2.1.0).
const String _hardMode = 'hard';

/// What opening the gate costs, spent through `SagaProgress.spendStars`.
const int _tollPrice = 5;

/// Which artwork supplies the map background.
enum DemoBackground { none, colour, svg, image, multiSvg }

/// Whether a completion is the normal game or a replay on hard (2.1.0).
enum DemoPlayMode { normal, hard }

/// How the character is drawn — the point being that the library does not care.
enum DemoCharacterArt { spriteSheet, flutterWidget }

class SagaMapDemoApp extends StatelessWidget {
  const SagaMapDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SagaMap Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF3F7D3A),
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const SagaMapDemo(),
    );
  }
}

class SagaMapDemo extends StatefulWidget {
  const SagaMapDemo({super.key});

  @override
  State<SagaMapDemo> createState() => _SagaMapDemoState();
}

class _SagaMapDemoState extends State<SagaMapDemo>
    with TickerProviderStateMixin {
  // --- Library controllers -------------------------------------------------

  /// Feeds chunks of generated levels to the map as it scrolls.
  late SagaInfiniteMapController _map;

  /// Owns the character's position and drives its movement.
  late SagaCharacterController _character;

  /// Lets the demo scroll the map from a button, outside the widget tree.
  final SagaMapCameraController _camera = SagaMapCameraController();

  // --- Game state ----------------------------------------------------------

  /// Progression state: which levels are locked, unlocked or done.
  SagaProgress _progress = SagaProgress.initial();

  /// Where boss drops are written (2.1.0). The use case writes to it through
  /// `executeAndPersist`; the demo only ever reads it back.
  InventoryRepository _inventoryRepository = InMemoryInventoryRepository();

  /// Boss drops collected so far, as last read back from
  /// [_inventoryRepository].
  List<InventoryItem> _inventory = const <InventoryItem>[];

  /// Persists the seed and the progress. 2.0.0 added `saveGlobalSeed` to this
  /// contract, so "regenerate the map" no longer means writing a default as a
  /// side effect of loading one.
  final SagaProgressRepository _repository =
      InMemorySagaProgressRepository(globalSeed: 42);

  /// Seed every level position is derived from. Mirrors what the repository
  /// holds; [_reseed] writes through [SagaProgressRepository.saveGlobalSeed]
  /// and reads back, rather than trusting this field.
  int _seed = 42;

  /// Highest level reached, which is where the path stops looking "walked".
  double _reached = 0;

  String _status = 'Tap a level to walk there';

  /// Which episode (chunk) the viewport is centred on. Driven by [onChunkEnter],
  /// a 1.1.0 listener that fires as the map scrolls into a new chunk.
  int _currentEpisode = 1;

  /// Swaps the built-in three biome ids for the demo's own five (2.0.0).
  /// Rebuilds the map, since biome ids are baked into generated levels.
  bool _hostRealms = false;

  /// Swaps the package's boss rule and loot table for the demo's own (2.0.0).
  /// Everything downstream — the node shape, the boss band, the drop-rate
  /// sheet and the reward itself — follows from this one flag.
  bool _customRewards = false;

  /// Applies [_demoPityRule] to boss drops (2.1.0).
  bool _pityOn = false;

  /// Normal play, or replaying levels on hard for a separate score (2.1.0).
  DemoPlayMode _playMode = DemoPlayMode.normal;

  /// The last level the character physically walked over, reported by
  /// [onLevelReached] (1.1.0) rather than inferred from completion.
  int _lastReachedLevel = 0;

  // --- Feature toggles -----------------------------------------------------

  SagaMapPathAxis _axis = SagaMapPathAxis.vertical;
  DemoBackground _background = DemoBackground.svg;
  DemoCharacterArt _art = DemoCharacterArt.spriteSheet;
  SagaCharacterGait _gait = SagaCharacterGait.walk;

  double _curvature = 0.85;
  double _zoom = 1;

  bool _showWalkedPath = true;
  bool _showScenery = true;
  bool _showEpisodeHeaders = true;
  bool _showParallax = true;
  bool _gateClosed = true;
  bool _pinchZoom = true;
  bool _followCharacter = true;
  bool _denseMobilePolicy = true;
  bool _boundedMemory = false;

  @override
  void initState() {
    super.initState();
    _map = _buildMapController();
    _character = _buildCharacterController();
    _loadStoredSeed();
  }

  /// Reads the seed the repository holds.
  ///
  /// Loading is inert by contract — it must not write a default back — so the
  /// map built above is discarded and rebuilt only if the stored seed differs
  /// from the one it was built with.
  Future<void> _loadStoredSeed() async {
    final stored = await _repository.loadGlobalSeed();
    if (!mounted || stored == _seed) return;
    setState(() {
      _seed = stored;
      _recreateMapController();
    });
  }

  @override
  void dispose() {
    _character.dispose();
    _camera.dispose();
    _map.dispose();
    super.dispose();
  }

  // --- Wiring --------------------------------------------------------------

  /// Builds the chunk controller for the current seed.
  ///
  /// The loader is deterministic — level N always lands in the same place for a
  /// given seed — which is what lets chunks be dropped and regenerated freely.
  /// `maxRetainedChunks` bounds memory on a long map by evicting chunks far
  /// from the one being read.
  SagaInfiniteMapController _buildMapController() {
    const generator = SagaMapLevelGenerator();
    return SagaInfiniteMapController(
      sectionsPerChunk: _levelsPerChunk,
      initialChunkCount: 3,
      loadBatchSize: 2,
      maxRetainedChunks: _boundedMemory ? 6 : null,
      chunkLoader: (chunkIndex, sectionsPerChunk) => generator.generateLevels(
        globalSeed: _seed,
        config: _mapConfig,
        startLevelId: chunkIndex * sectionsPerChunk,
        count: sectionsPerChunk,
      ),
    );
  }

  /// Builds the character controller.
  ///
  /// No barrier is wired here any more: since 2.0.0 the view applies
  /// [clampTravelThroughGates] itself from its `gates` list. Whether the gate
  /// is open stays the host's decision — see [_gates] and [_gateOpen].
  SagaCharacterController _buildCharacterController() {
    return SagaCharacterController(
      vsync: this,
      gait: _gait,
      stepDuration: const Duration(milliseconds: 380),
      stepPause: const Duration(milliseconds: 80),
    );
  }

  /// The demo's one gate, handed straight to the view.
  List<SagaMapGate> get _gates => [
        SagaMapGate(
          pathPosition: _gateLevel.toDouble(),
          isOpen: !_gateClosed,
        ),
      ];

  /// The single gate condition all three 2.0.0 hooks are derived from.
  ///
  /// Deriving them from one predicate is the point: a node the player can tap
  /// but whose successor never unlocks reads as a broken map.
  bool _gateOpen(int levelId) => !_gateClosed || levelId < _gateLevel;

  /// Rebuilds the map for a new seed, resetting progress with it.
  ///
  /// The seed is persisted through [SagaProgressRepository.saveGlobalSeed]
  /// (2.0.0) and read back, so what the map draws is what a restart would
  /// restore rather than a field that only agrees with storage by luck.
  ///
  /// Progress is reset here on purpose. A `SagaProgress` survives a reseed —
  /// it keeps its level ids — but those ids now point at different terrain,
  /// so carrying it over would claim the player had cleared levels of a world
  /// that never existed.
  Future<void> _reseed(int seed) async {
    await _repository.saveGlobalSeed(seed);
    final stored = await _repository.loadGlobalSeed();
    if (!mounted) return;

    setState(() {
      _seed = stored;
      _progress = SagaProgress.initial();
      _inventoryRepository = InMemoryInventoryRepository();
      _inventory = const <InventoryItem>[];
      _reached = 0;
      _status = 'New map from seed $stored';
      _recreateMapController();
    });
    _character.jumpTo(0);
  }

  /// Swaps the generator's biome ids between the package's three and the
  /// demo's five (2.0.0), rebuilding the map because biome ids are baked into
  /// generated levels.
  void _setHostRealms(bool enabled) {
    setState(() {
      _hostRealms = enabled;
      _status = enabled
          ? 'Generating ${_demoRealms.length} host realms'
          : 'Back to the built-in biome ids';
      _recreateMapController();
    });
  }

  /// Swaps the gait, which needs a fresh controller since gait is fixed at
  /// construction. The character keeps its place across the swap.
  void _setGait(SagaCharacterGait gait) {
    final at = _character.pathPosition;
    setState(() {
      _gait = gait;
      _character.dispose();
      _character = _buildCharacterController();
    });
    _character.jumpTo(at);
  }

  /// Replaces the map controller with one built from the current seed and
  /// config. Call inside a [setState]; [_rebuildMap] is the wrapper for
  /// callers that are not already in one.
  void _recreateMapController() {
    _map.dispose();
    _map = _buildMapController();
  }

  /// Rebuilds the map controller in place, for toggles that change how chunks
  /// are loaded rather than how they are drawn.
  void _rebuildMap() => setState(_recreateMapController);

  // --- Gameplay ------------------------------------------------------------

  /// Walks the character to [level], then completes it.
  ///
  /// If a closed gate sits in between the controller stops there and the level
  /// is not completed — the walk simply ends short.
  Future<void> _goToLevel(LevelData level) async {
    final target = level.id.toDouble();
    setState(() => _status = 'Walking to level ${level.id}…');

    await _character.moveTo(target);
    if (!mounted) return;

    if (_character.pathPosition < target) {
      setState(() => _status = 'A closed gate blocks the way at $_gateLevel');
      return;
    }

    await _completeLevel(level.id);
  }

  /// Applies the completion use-case and folds the result back into state.
  ///
  /// The use-case keeps the better of the old and new star counts, unlocks the
  /// next level, and mints a boss reward only on a first clear. Since 2.1.0 it
  /// also writes that reward: `executeAndPersist` hands it to the injected
  /// repository before returning, so there is no list for the demo to forget
  /// to add it to.
  ///
  /// On hard the same call records a separate mode score instead, and does
  /// nothing else — no unlock, no loot.
  Future<void> _completeLevel(int levelId) async {
    final pityBefore = _pityCounter;
    final useCase = CompleteLevelUseCase(
      bossRule: _bossRule,
      lootTable: _lootTable,
      // The gate refuses to open the successor. The level itself still
      // completes and a boss reward still drops — the player did clear it.
      canUnlock: _gateOpen,
      pityRule: _pityOn ? _demoPityRule : null,
      inventory: _inventoryRepository,
    );

    final CompleteLevelResult result;
    try {
      result = await useCase.executeAndPersist(
        currentProgress: _progress,
        levelId: levelId,
        globalSeed: _seed,
        // Normal and hard score differently on purpose, so the two can be seen
        // not to overwrite each other.
        stars: _playMode == DemoPlayMode.hard
            ? kMaxLevelStars - levelId % kMaxLevelStars
            : 1 + levelId % kMaxLevelStars,
        pityCounter: pityBefore,
        modeId: _modeId,
      );
    } catch (error) {
      // A failed write is no completion at all: there is no progress to keep.
      if (mounted) setState(() => _status = 'Reward not saved: $error');
      return;
    }
    final items = await _inventoryRepository.getItems();
    if (!mounted) return;

    setState(() {
      var next = result.nextProgress;
      final reward = result.reward;
      // The counter is save data, so it lives in the host's `extra`; how it
      // moves is the rule's decision, not the demo's.
      if (reward != null && _pityOn) {
        next = next.copyWith(extra: {
          ...next.extra,
          _pityKey: _demoPityRule.nextCounter(pityBefore, reward.rarity),
        });
      }
      _progress = next;
      _inventory = items;
      // The walked path is painted up to here.
      _reached = _reached > levelId ? _reached : levelId.toDouble();

      if (_modeId != null) {
        final best = next.levels[levelId]?.starsFor(_hardMode) ?? 0;
        _status = 'Hard run on level $levelId: best ★$best — nothing unlocks';
      } else if (reward != null) {
        final forced = _pityOn && _demoPityRule.isDue(pityBefore);
        _status = 'Boss cleared — found ${reward.itemName} '
            '(${reward.rarity.name}${forced ? ', pity' : ''})';
      } else if (result.unlockBlocked) {
        // 2.0.0: the completion stands, the road ahead does not open.
        _status = 'Level $levelId complete — the gate ahead is still shut';
      } else {
        _status = 'Level $levelId complete';
      }
    });
  }

  /// Spends stars to open the gate (2.1.0).
  ///
  /// `spendStars` returns `null` rather than throwing when the player is
  /// short, so "not enough stars" is a branch here rather than a crash.
  void _payToll() {
    setState(() {
      if (!_gateClosed) {
        _status = 'The gate at level $_gateLevel is already open';
        return;
      }
      final paid = _progress.spendStars(_tollPrice);
      if (paid == null) {
        _status = 'The toll is $_tollPrice★ — '
            'you have ${_progress.availableStars}★';
        return;
      }
      _progress = paid;
      _gateClosed = false;
      _status = 'Paid $_tollPrice★ — the gate at level $_gateLevel is open';
    });
  }

  /// The geometry the map generates against.
  ///
  /// `biomeSpan` drops to 10 alongside the demo's realms so the cycle is
  /// actually visible: five realms x 10 levels closes in 50, against the
  /// default three x 50 which takes 150.
  SagaMapConfig get _mapConfig => _hostRealms
      ? _baseMapConfig.copyWith(biomeSpan: 10, biomeIds: _demoRealms)
      : _baseMapConfig;

  /// The boss rule currently in force, package default or the demo's own.
  SagaBossRule get _bossRule =>
      _customRewards ? _frequentBossRule : isBossLevel;

  /// The table currently in force. Paired with [_bossRule]: swapping one
  /// without the other is how a host ends up disclosing odds it never rolls.
  List<LootTableEntry> get _lootTable =>
      _customRewards ? _demoLootTable : kMvpLootTable;

  /// The stored pity counter, `0` on a fresh save.
  int get _pityCounter => (_progress.extra[_pityKey] as int?) ?? 0;

  /// The mode id a completion is recorded under; `null` is the normal game.
  String? get _modeId => _playMode == DemoPlayMode.hard ? _hardMode : null;

  /// Progress lookup the map uses to style each node.
  LevelProgress? _progressFor(LevelData level) => _progress.levels[level.id];

  /// Long-press handler (1.1.0). Shows the boss drop-rate breakdown computed
  /// with the `LootTableOdds` extension, and a bookmark toggle that stores a
  /// host-owned flag in `LevelProgress.extra` — data the library persists
  /// through JSON without knowing what it means.
  void _showLevelInfo(LevelData level) {
    final displayId = level.id + 1;
    final progress = _progressFor(level);
    final bookmarked = (progress?.extra['bookmarked'] as bool?) ?? false;

    // Odds only make sense where a reward actually rolls: boss levels. Both
    // halves come from the injected pair, so the disclosed rates cannot drift
    // away from what the use case actually rolls.
    final odds = _bossRule(level.id) ? _lootTable : const <LootTableEntry>[];

    showDialog<void>(
      context: context,
      builder: (dialogContext) => AlertDialog(
        title: Text('Level $displayId'),
        content: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 2.1.0: both scores, read through `starsFor` — `null` is the
            // normal game, and a hard run never overwrote it.
            Text('Normal ★${progress?.starsFor(null) ?? 0}   '
                'Hard ★${progress?.starsFor(_hardMode) ?? 0}'),
            const SizedBox(height: 8),
            if (odds.isEmpty)
              const Text('A normal level — no boss loot rolls here.')
            else ...[
              if (_pityOn) ...[
                Text(
                  _demoPityRule.isDue(_pityCounter)
                      ? 'Pity: this drop is guaranteed rare or better'
                      : 'Pity: $_pityCounter of ${_demoPityRule.threshold} '
                          'drops below rare',
                ),
                const SizedBox(height: 6),
              ],
              const Text('Boss drop rates',
                  style: TextStyle(fontWeight: FontWeight.bold)),
              const SizedBox(height: 6),
              for (final entry in odds)
                Text('• ${entry.itemName}: '
                    '${(odds.probabilityOf(entry) * 100).toStringAsFixed(1)}%'),
              const Divider(),
              for (final MapEntry(:key, :value) in odds.rarityOdds().entries)
                Text('${key.name}: ${(value * 100).toStringAsFixed(1)}%',
                    style:
                        const TextStyle(color: Colors.white70, fontSize: 12)),
            ],
          ],
        ),
        actions: [
          TextButton.icon(
            icon: Icon(bookmarked ? Icons.bookmark : Icons.bookmark_border),
            label: Text(bookmarked ? 'Bookmarked' : 'Bookmark'),
            onPressed: () {
              _toggleBookmark(level.id, !bookmarked);
              Navigator.of(dialogContext).pop();
            },
          ),
          TextButton(
            onPressed: () => Navigator.of(dialogContext).pop(),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  /// Writes a host-owned `bookmarked` flag into that level's progress `extra`,
  /// creating a progress entry if the level has none yet.
  void _toggleBookmark(int levelId, bool value) {
    setState(() {
      final levels = Map<int, LevelProgress>.from(_progress.levels);
      final existing = levels[levelId] ??
          LevelProgress(levelId: levelId, state: LevelCompletionState.locked);
      final extra = Map<String, dynamic>.from(existing.extra)
        ..['bookmarked'] = value;
      levels[levelId] = existing.copyWith(extra: extra);
      _progress = _progress.copyWith(levels: levels);
      _status = value
          ? 'Bookmarked level ${levelId + 1}'
          : 'Removed bookmark on level ${levelId + 1}';
    });
  }

  // --- Builders the map calls ----------------------------------------------

  /// Resolves the responsive policy: orientation plus an optional touch-first
  /// density that enlarges nodes and their touch targets on phones.
  SagaResponsiveResolver get _responsiveResolver {
    var config = SagaMapResponsiveConfig.defaults.copyWith(pathAxis: _axis);
    if (_denseMobilePolicy) {
      config = config.copyWith(
        nodeSizePolicy: const SagaMapValuePolicy(
          mobile: 1.35,
          tablet: 1.1,
          desktop: 1.0,
          ultra4k: 0.95,
        ),
        interactionRadiusPolicy: const SagaMapValuePolicy(
          mobile: 1.3,
          tablet: 1.1,
          desktop: 1.0,
          ultra4k: 1.0,
        ),
      );
    }
    return SagaResponsiveResolver(config: config);
  }

  /// Picks the background artwork for the current toggle.
  ///
  /// Multi-asset modes cycle their list across chunks; `overflowBehavior`
  /// decides what happens once the chunks outrun the artwork.
  SagaMapBackgroundConfig get _backgroundConfig {
    final horizontal = _axis == SagaMapPathAxis.horizontal;
    switch (_background) {
      case DemoBackground.none:
        return const SagaMapBackgroundConfig.none();
      case DemoBackground.colour:
        return const SagaMapBackgroundConfig.color(color: Color(0xFF24451F));
      case DemoBackground.svg:
        // 2.0.0: the package no longer depends on flutter_svg. SVG is now the
        // host's business — this app carries the dependency and hands back a
        // widget, and the package positions and scrolls it without knowing
        // what it is.
        final asset = horizontal
            ? 'assets/svg/map_horizontal.svg'
            : 'assets/svg/map_vertical.svg';
        return SagaMapBackgroundConfig.builder(
          (context, chunkIndex) => SvgPicture.asset(asset, fit: BoxFit.cover),
        );
      case DemoBackground.image:
        return SagaMapBackgroundConfig.imageAsset(
          assetPath: horizontal
              ? 'assets/drawable/map_horizontal.png'
              : 'assets/drawable/map_vertical.png',
          fit: BoxFit.cover,
        );
      case DemoBackground.multiSvg:
        // The builder is handed the chunk index, which is how a host
        // reproduces what the removed `svgAssets` constructor did — including
        // choosing its own overflow behaviour, here a loop.
        const assets = [
          'assets/svg/map_vertical.svg',
          'assets/svg/map_horizontal.svg',
        ];
        return SagaMapBackgroundConfig.builder(
          (context, chunkIndex) => SvgPicture.asset(
            assets[((chunkIndex ?? 0) % assets.length + assets.length) %
                assets.length],
            fit: BoxFit.cover,
          ),
        );
    }
  }

  /// Draws one level node.
  ///
  /// The builder is handed the resolved layout so it can scale with the map.
  /// Progress decides the colour; boss levels get a different shape and the
  /// current level a white ring.
  Widget _buildNode(
    BuildContext context,
    LevelData level,
    ResolvedSagaLayout layout,
  ) {
    final progress = _progressFor(level);
    final state = progress?.state ?? LevelCompletionState.locked;
    final isCurrent = level.id == _progress.currentMaxUnlockedLevelId;
    final isBoss = _bossRule(level.id);
    final beatenOnHard = (progress?.starsFor(_hardMode) ?? 0) > 0;

    // 2.0.0: the round trip the opaque asset map exists for. The biome id was
    // generated from `SagaMapConfig.biomeIds`, resolved to a theme, and the
    // key that comes back is one this demo put there — the package carried it
    // without ever reading it. Only shown for the demo's own realms, where the
    // letter means something.
    final emblem = _hostRealms
        ? const _DemoBiomeTheme()
            .resolve(level.biomeId)
            .assets[_DemoBiomeTheme.emblemKey]
        : null;

    final Color fill;
    final Color border;
    switch (state) {
      case LevelCompletionState.completed:
        fill = const Color(0xFFFFC107);
        border = const Color(0xFF8A6100);
      case LevelCompletionState.unlocked:
        fill = const Color(0xFF7BC96F);
        border = const Color(0xFF33632C);
      case LevelCompletionState.locked:
        fill = const Color(0xFF6B6B6B);
        border = const Color(0xFF3A3A3A);
    }

    return DecoratedBox(
      decoration: BoxDecoration(
        color: fill,
        shape: isBoss ? BoxShape.rectangle : BoxShape.circle,
        borderRadius: isBoss ? BorderRadius.circular(10) : null,
        // 2.1.0: a red ring marks a level beaten on hard. It is read through
        // `starsFor`, so the normal stars drawn inside are untouched.
        border: Border.all(
          color: isCurrent
              ? Colors.white
              : (beatenOnHard ? const Color(0xFFE53935) : border),
          width: isCurrent || beatenOnHard ? 3 : 2,
        ),
        boxShadow: const [
          BoxShadow(color: Colors.black38, blurRadius: 4, offset: Offset(0, 2)),
        ],
      ),
      child: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              '${level.id + 1}',
              style: TextStyle(
                color: state == LevelCompletionState.locked
                    ? Colors.white70
                    : Colors.black87,
                fontWeight: FontWeight.bold,
                fontSize: 13,
              ),
            ),
            if (emblem != null)
              Text(
                emblem,
                style: TextStyle(
                  color: state == LevelCompletionState.locked
                      ? Colors.white54
                      : Colors.black54,
                  fontSize: 8,
                  fontWeight: FontWeight.bold,
                ),
              )
            else if ((progress?.stars ?? 0) > 0)
              Text(
                '★' * progress!.stars,
                style: const TextStyle(color: Colors.black87, fontSize: 8),
              ),
            // A host-owned bookmark flag stored in LevelProgress.extra (1.1.0).
            if ((progress?.extra['bookmarked'] as bool?) ?? false)
              const Icon(Icons.bookmark, color: Color(0xFF1B3A17), size: 10),
          ],
        ),
      ),
    );
  }

  /// Draws the character.
  ///
  /// The library never draws it — it only reports where the character is, which
  /// way it faces and whether it is moving. That is what lets a sprite sheet
  /// and a plain Flutter widget swap freely here, and why Lottie or Rive would
  /// drop into the same place.
  Widget _buildCharacter(BuildContext context, SagaCharacterState state) {
    final walking = state.motion == SagaCharacterMotion.walking;

    if (_art == DemoCharacterArt.spriteSheet) {
      return SagaSpriteAnimation(
        image: const AssetImage('assets/drawable/hero_walk.png'),
        sheet: _heroSheet,
        // Walking plays the whole strip; standing still holds one frame.
        clip: walking
            ? const SagaSpriteClip(count: 6, fps: 10)
            : const SagaSpriteClip(count: 1),
        // The path already accounts for right-to-left, so this is purely
        // "which way is the art facing".
        flipHorizontally: state.facesLeft,
        playing: walking,
      );
    }

    return Transform(
      alignment: Alignment.center,
      transform: Matrix4.identity()
        ..scaleByDouble(state.facesLeft ? -1.0 : 1.0, 1.0, 1.0, 1.0),
      child: DecoratedBox(
        decoration: BoxDecoration(
          color: walking ? Colors.deepOrange : Colors.orangeAccent,
          borderRadius: BorderRadius.circular(10),
          border: Border.all(color: Colors.white, width: 2),
        ),
        child: const Center(
          child: Icon(Icons.directions_walk, color: Colors.white, size: 22),
        ),
      ),
    );
  }

  /// Scatters scenery over a chunk.
  ///
  /// Decorations are positioned by the library but drawn by the host, and they
  /// sit below the path and nodes — so a tree can overlap a level without ever
  /// stealing the tap that opens it.
  ///
  /// 1.1.0: the builder is handed a [SagaChunkContext] instead of a bare index,
  /// so it can read the chunk's levels and dominant biome. Here the tree colour
  /// follows `chunk.dominantBiomeId`, and `SagaMapDecoration.atLevel` lays a
  /// full-width band across every boss level without any coordinate maths.
  List<SagaMapDecoration> _buildScenery(
    BuildContext context,
    SagaChunkContext chunk,
  ) {
    if (!_showScenery) return const <SagaMapDecoration>[];
    final base = chunk.chunkIndex * _levelsPerChunk;
    final treeColor = _biomeTint(chunk.dominantBiomeId);

    return <SagaMapDecoration>[
      // Trees hugging the path, alternating sides, tinted by the chunk's biome.
      for (var i = 1; i < _levelsPerChunk; i += 3)
        SagaMapDecoration.besidePath(
          pathPosition: (base + i).toDouble(),
          lateralOffset: i.isEven ? 96 : -96,
          size: const Size(40, 46),
          builder: (context) => Icon(Icons.park, color: treeColor, size: 40),
        ),
      // A cloud floating free of the path, placed by chunk fraction.
      SagaMapDecoration.atFraction(
        chunkFraction: Offset(chunk.chunkIndex.isEven ? 0.2 : 0.8, 0.35),
        size: const Size(56, 34),
        builder: (context) =>
            const Icon(Icons.cloud, color: Color(0x55FFFFFF), size: 52),
      ),
      // 1.1.0: a boss band aligned to the level itself, not to raw pixels. It
      // spans the chunk's full width and ignores the path's lateral wander.
      for (final level in chunk.levels)
        if (_bossRule(level.id))
          SagaMapDecoration.atLevel(
            levelId: level.id,
            height: 46,
            builder: (context) => DecoratedBox(
              decoration: BoxDecoration(
                gradient: LinearGradient(
                  colors: [
                    const Color(0x00FFC107),
                    const Color(0xFFFFC107).withValues(alpha: 0.35),
                    const Color(0x00FFC107),
                  ],
                ),
              ),
            ),
          ),
    ];
  }

  /// A rough tint per biome, used to colour scenery from the chunk context.
  Color _biomeTint(String biomeId) {
    switch (biomeId) {
      case kBiomeIdDesert:
        return const Color(0xFFB58A3A);
      case kBiomeIdGlacier:
        return const Color(0xFF6FA8C9);
      case kBiomeIdForest:
      default:
        return const Color(0xFF2F6B2A);
    }
  }

  /// A banner announcing each new episode, shown before its chunk in the
  /// scroll direction.
  ///
  /// 1.1.0: the builder receives a [SagaChunkContext], so the banner can report
  /// how many stars the player has earned across this chunk's levels using the
  /// `SagaProgressStars.starsInRange` helper — no manual loop over levels.
  Widget? _buildEpisodeHeader(BuildContext context, SagaChunkContext chunk) {
    if (!_showEpisodeHeaders) return null;

    final earned = _progress.starsInRange(
      chunk.chunkIndex * _levelsPerChunk,
      _levelsPerChunk,
    );

    final banner = Container(
      alignment: Alignment.center,
      padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
      decoration: BoxDecoration(
        color: const Color(0xCC1B3A17),
        border: Border.all(color: const Color(0xFF7BC96F), width: 2),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        'EPISODE ${chunk.chunkIndex + 1}   ★ $earned',
        style: const TextStyle(
          color: Color(0xFFFFC107),
          fontWeight: FontWeight.bold,
          letterSpacing: 2,
        ),
      ),
    );

    return Padding(
      padding: _axis == SagaMapPathAxis.vertical
          ? const EdgeInsets.symmetric(vertical: 12)
          : const EdgeInsets.symmetric(horizontal: 12),
      child: Center(child: banner),
    );
  }

  /// The layer behind the map that scrolls slower than it, for depth.
  Widget? get _parallaxLayer {
    if (!_showParallax) return null;
    return const DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [Color(0xFF12331B), Color(0xFF2C5E3A), Color(0xFF12331B)],
        ),
      ),
      child: SizedBox.expand(),
    );
  }

  // --- UI ------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            Expanded(child: _buildMap()),
            _buildStatusBar(),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _openControls,
        icon: const Icon(Icons.tune),
        label: const Text('Features'),
      ),
    );
  }

  /// The map itself, with every feature wired to the current toggles.
  Widget _buildMap() {
    return SagaInfiniteMapView(
      controller: _map,
      // Sizing is expressed along the path axis, whichever screen axis that is.
      chunkExtent: _levelsPerChunk * _pixelsPerLevel,
      chunkSpanNormalized: _mapConfig.spanForLevelCount(_levelsPerChunk),
      // Centres the generated zig-zag band identically in every chunk.
      lateralBounds: _mapConfig.lateralBounds,
      biomeThemeResolver: const _DemoBiomeTheme(),
      responsiveResolver: _responsiveResolver,
      backgroundConfig: _backgroundConfig,

      // Path shape and progress.
      pathCurvature: _curvature,
      pathProgressPosition: _showWalkedPath ? _reached : null,

      // Scenery, banners, depth. 1.1.0 context-aware builders.
      chunkDecorationBuilder: _buildScenery,
      chunkEpisodeHeaderBuilder: _buildEpisodeHeader,
      parallaxBackground: _parallaxLayer,
      parallaxFactor: 0.35,

      // 1.1.0 listeners: which chunk the viewport entered, and which level the
      // character walked over. Both are debounced by the library, so scrolling
      // back and forth across a seam does not spam these callbacks.
      onChunkEnter: (chunk) =>
          setState(() => _currentEpisode = chunk.chunkIndex + 1),
      onLevelReached: (level) =>
          setState(() => _lastReachedLevel = level.id + 1),

      // 1.1.0: long-press a node for its drop-rate breakdown. Locked nodes are
      // filtered out by the interaction policy's canLongPress.
      onLevelLongPress: _showLevelInfo,

      // The character and the camera that follows it.
      character: SagaCharacter(
        controller: _character,
        size: const Size(44, 58),
        builder: _buildCharacter,
        semanticsLabel: 'Player',
      ),
      followCharacter: _followCharacter,
      cameraController: _camera,
      // Opens where the player left off rather than at level zero.
      initialPathPosition: _reached,

      // Pinch to zoom; one finger still scrolls.
      zoomConfig:
          _pinchZoom ? const SagaMapZoomConfig(min: 0.6, max: 2.4) : null,
      onZoomChanged: (zoom) => setState(() => _zoom = zoom),

      // The view clamps travel through these itself (2.0.0); the host no
      // longer installs a barrier on the controller.
      gates: _gates,

      // Interaction.
      progressResolver: _progressFor,
      interactionPolicy: SagaNodeInteractionPolicy(
        emitTapForLockedNode: false,
        emitTapForCompletedNode: true,
        // Nodes past a closed gate stop responding entirely: disabled,
        // skipped by Tab and announced as locked (2.0.0).
        isReachable: (level, progress) => _gateOpen(level.id),
      ),
      onLevelTap: _goToLevel,
      nodeBuilder: _buildNode,
    );
  }

  /// A one-line readout of what the map is currently doing.
  Widget _buildStatusBar() {
    return Container(
      width: double.infinity,
      color: Colors.black54,
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(_status, style: const TextStyle(fontWeight: FontWeight.w600)),
          const SizedBox(height: 2),
          Text(
            'seed $_seed · episode $_currentEpisode · '
            'reached L$_lastReachedLevel · '
            '★ ${_progress.availableStars}/${_progress.totalStars} · '
            'items ${_inventory.length} · '
            '${_playMode == DemoPlayMode.hard ? 'hard · ' : ''}'
            'zoom ${_zoom.toStringAsFixed(2)} · '
            '${_axis == SagaMapPathAxis.vertical ? 'vertical' : 'horizontal'}',
            style: const TextStyle(fontSize: 11, color: Colors.white70),
          ),
        ],
      ),
    );
  }

  /// The feature panel. Every control here maps to one library capability.
  void _openControls() {
    showModalBottomSheet<void>(
      context: context,
      isScrollControlled: true,
      showDragHandle: true,
      builder: (sheetContext) => StatefulBuilder(
        builder: (sheetContext, setSheetState) {
          // Applies a change to both the sheet and the map behind it.
          void update(VoidCallback fn) {
            setState(fn);
            setSheetState(() {});
          }

          return SizedBox(
            height: MediaQuery.sizeOf(sheetContext).height * 0.75,
            child: ListView(
              padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
              children: [
                _section('Path'),
                SegmentedButton<SagaMapPathAxis>(
                  segments: const [
                    ButtonSegment(
                      value: SagaMapPathAxis.vertical,
                      label: Text('Vertical'),
                      icon: Icon(Icons.swap_vert),
                    ),
                    ButtonSegment(
                      value: SagaMapPathAxis.horizontal,
                      label: Text('Horizontal'),
                      icon: Icon(Icons.swap_horiz),
                    ),
                  ],
                  selected: {_axis},
                  onSelectionChanged: (s) => update(() => _axis = s.first),
                ),
                _slider(
                  label: 'Curvature',
                  value: _curvature,
                  onChanged: (v) => update(() => _curvature = v),
                ),
                SwitchListTile(
                  title: const Text('Light up the walked path'),
                  subtitle: const Text('Bright behind you, dim ahead'),
                  value: _showWalkedPath,
                  onChanged: (v) => update(() => _showWalkedPath = v),
                ),
                _section('Character'),
                SegmentedButton<DemoCharacterArt>(
                  segments: const [
                    ButtonSegment(
                      value: DemoCharacterArt.spriteSheet,
                      label: Text('Sprite sheet'),
                    ),
                    ButtonSegment(
                      value: DemoCharacterArt.flutterWidget,
                      label: Text('Widget'),
                    ),
                  ],
                  selected: {_art},
                  onSelectionChanged: (s) => update(() => _art = s.first),
                ),
                SegmentedButton<SagaCharacterGait>(
                  segments: const [
                    ButtonSegment(
                      value: SagaCharacterGait.walk,
                      label: Text('Walk'),
                    ),
                    ButtonSegment(
                      value: SagaCharacterGait.hop,
                      label: Text('Hop'),
                    ),
                  ],
                  selected: {_gait},
                  onSelectionChanged: (s) {
                    _setGait(s.first);
                    setSheetState(() {});
                  },
                ),
                SwitchListTile(
                  title: const Text('Camera follows the character'),
                  value: _followCharacter,
                  onChanged: (v) => update(() => _followCharacter = v),
                ),
                ListTile(
                  leading: const Icon(Icons.my_location),
                  title: const Text('Scroll to the character'),
                  subtitle: const Text('SagaMapCameraController'),
                  onTap: () => _camera.scrollToCharacter(),
                ),
                _section('Scenery'),
                SwitchListTile(
                  title: const Text('Decorations'),
                  subtitle: const Text('Trees and clouds, below the nodes'),
                  value: _showScenery,
                  onChanged: (v) => update(() => _showScenery = v),
                ),
                SwitchListTile(
                  title: const Text('Episode headers'),
                  value: _showEpisodeHeaders,
                  onChanged: (v) => update(() => _showEpisodeHeaders = v),
                ),
                SwitchListTile(
                  title: const Text('Parallax background'),
                  value: _showParallax,
                  onChanged: (v) => update(() => _showParallax = v),
                ),
                _backgroundPicker(update),
                _section('Rules'),
                SwitchListTile(
                  title: const Text('Host realms'),
                  subtitle: Text(
                    _hostRealms
                        ? '${_demoRealms.length} own ids, 10 levels each, '
                            'with an ambient wash'
                        : 'Built-in biome ids: ${kSagaBiomeIds.length}, '
                            '50 levels each',
                  ),
                  value: _hostRealms,
                  onChanged: (v) {
                    _setHostRealms(v);
                    setSheetState(() {});
                  },
                ),
                SwitchListTile(
                  title: const Text('Custom rewards'),
                  subtitle: const Text(
                    'Injected bossRule + lootTable: a boss every 5th level',
                  ),
                  value: _customRewards,
                  onChanged: (v) => update(() => _customRewards = v),
                ),
                SwitchListTile(
                  title: Text('Gate closed at level $_gateLevel'),
                  subtitle: const Text('Holds the character on the near side'),
                  value: _gateClosed,
                  onChanged: (v) => update(() => _gateClosed = v),
                ),
                SwitchListTile(
                  title: const Text('Pinch to zoom'),
                  subtitle: const Text('One finger still scrolls'),
                  value: _pinchZoom,
                  onChanged: (v) => update(() => _pinchZoom = v),
                ),
                SwitchListTile(
                  title: const Text('Touch-first density'),
                  subtitle: const Text('Bigger nodes and targets on phones'),
                  value: _denseMobilePolicy,
                  onChanged: (v) => update(() => _denseMobilePolicy = v),
                ),
                SwitchListTile(
                  title: const Text('Bounded memory'),
                  subtitle: const Text('Evict chunks far from the viewport'),
                  value: _boundedMemory,
                  onChanged: (v) {
                    setState(() => _boundedMemory = v);
                    _rebuildMap();
                    setSheetState(() {});
                  },
                ),
                _section('Economy'),
                SwitchListTile(
                  title: const Text('Pity rule'),
                  subtitle: Text(
                    'After ${_demoPityRule.threshold} drops below rare, the '
                    'next boss is rare or better · counter $_pityCounter',
                  ),
                  value: _pityOn,
                  onChanged: (v) => update(() => _pityOn = v),
                ),
                SegmentedButton<DemoPlayMode>(
                  segments: const [
                    ButtonSegment(
                      value: DemoPlayMode.normal,
                      label: Text('Normal'),
                      icon: Icon(Icons.flag),
                    ),
                    ButtonSegment(
                      value: DemoPlayMode.hard,
                      label: Text('Hard replay'),
                      icon: Icon(Icons.whatshot),
                    ),
                  ],
                  selected: {_playMode},
                  onSelectionChanged: (s) => update(() => _playMode = s.first),
                ),
                ListTile(
                  leading: const Icon(Icons.toll),
                  title: const Text('Pay the toll'),
                  subtitle: Text(
                    '$_tollPrice★ opens the gate · '
                    '${_progress.availableStars} of '
                    '${_progress.totalStars}★ available',
                  ),
                  onTap: () {
                    _payToll();
                    setSheetState(() {});
                  },
                ),
                _section('Map'),
                ListTile(
                  leading: const Icon(Icons.casino),
                  title: const Text('New seed'),
                  subtitle: Text('Currently $_seed'),
                  onTap: () {
                    _reseed(_seed + 1);
                    setSheetState(() {});
                  },
                ),
                if (_inventory.isNotEmpty)
                  ListTile(
                    leading: const Icon(Icons.inventory_2),
                    title:
                        const Text('Boss drops, as the repository holds them'),
                    subtitle: Text(_inventory
                        .map((i) => '${i.itemName} (${i.rarity.name})')
                        .join(', ')),
                  ),
              ],
            ),
          );
        },
      ),
    );
  }

  /// A labelled divider inside the control sheet.
  Widget _section(String title) => Padding(
        padding: const EdgeInsets.only(top: 18, bottom: 6),
        child: Text(
          title.toUpperCase(),
          style: const TextStyle(
            fontSize: 11,
            letterSpacing: 1.5,
            fontWeight: FontWeight.bold,
            color: Color(0xFF9ED89A),
          ),
        ),
      );

  /// A `0..1` slider with its value shown alongside.
  Widget _slider({
    required String label,
    required double value,
    required ValueChanged<double> onChanged,
  }) {
    return Row(
      children: [
        SizedBox(width: 96, child: Text(label)),
        Expanded(
          child: Slider(
            value: value,
            divisions: 20,
            label: value.toStringAsFixed(2),
            onChanged: onChanged,
          ),
        ),
        SizedBox(width: 40, child: Text(value.toStringAsFixed(2))),
      ],
    );
  }

  /// Background artwork picker.
  Widget _backgroundPicker(void Function(VoidCallback) update) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8),
      child: Wrap(
        spacing: 8,
        children: [
          for (final mode in DemoBackground.values)
            ChoiceChip(
              label: Text(_backgroundLabel(mode)),
              selected: _background == mode,
              onSelected: (_) => update(() => _background = mode),
            ),
        ],
      ),
    );
  }

  /// Human-readable name for a background mode.
  String _backgroundLabel(DemoBackground mode) {
    switch (mode) {
      case DemoBackground.none:
        return 'None';
      case DemoBackground.colour:
        return 'Colour';
      case DemoBackground.svg:
        return 'SVG';
      case DemoBackground.image:
        return 'Image';
      case DemoBackground.multiSvg:
        return 'Multi-SVG';
    }
  }
}

/// Biome colours for the demo.
///
/// The upcoming colours are what create the "road lights up as you go" look:
/// the stretch the player has covered uses the bright pair, everything ahead
/// the muted one.
class _DemoBiomeTheme implements SagaBiomeThemeResolver {
  const _DemoBiomeTheme();

  /// Palette, wash and emblem per biome id.
  ///
  /// Covers the package's three and the demo's five. Writing a resolver is
  /// what a host with its own `SagaMapConfig.biomeIds` does instead of living
  /// with `DefaultSagaBiomeThemeResolver`, which knows only the built-in three
  /// and falls back to forest for everything else.
  static const Map<String, (Color, Color, Color?, String)> _palette = {
    // id: (background, walked path, ambient wash, emblem)
    kBiomeIdForest: (Color(0xFF2D5A27), Color(0xFFFFC107), null, 'F'),
    kBiomeIdDesert: (Color(0xFF6E5A2A), Color(0xFFFFD466), null, 'D'),
    kBiomeIdGlacier: (Color(0xFF2A4A5C), Color(0xFF9FE3FF), null, 'G'),
    'sunspire': (Color(0xFF7A3E12), Color(0xFFFFB74D), Color(0x1AFF7043), 'S'),
    'drownlands': (
      Color(0xFF14323F),
      Color(0xFF5FC7D6),
      Color(0x1A00ACC1),
      'W'
    ),
    'ashreach': (Color(0xFF3A2F35), Color(0xFFBFA8B4), Color(0x22000000), 'A'),
    'verdant': (Color(0xFF1E4A22), Color(0xFF9CCC65), Color(0x1A7CB342), 'V'),
    'gloamvale': (Color(0xFF2C2340), Color(0xFFB39DDB), Color(0x223F2A6E), 'M'),
  };

  /// The emblem key this demo agrees on with itself.
  ///
  /// `SagaBiomeTheme.assets` is an opaque `Map<String, String>`: the package
  /// carries it and never reads it, so the key namespace is entirely the
  /// host's. See `_buildNode`, which reads it back.
  static const String emblemKey = 'emblem';

  @override
  SagaBiomeTheme resolve(String biomeId) {
    // Falls back to forest for an id this demo has not themed, matching what
    // the built-in resolver does — a wrong-green map beats a crash.
    final (background, walked, tint, emblem) =
        _palette[biomeId] ?? _palette[kBiomeIdForest]!;

    return SagaBiomeTheme(
      backgroundColor: background,
      pathFillColor: walked,
      pathBorderColor: const Color(0xFF5A4600),
      upcomingPathFillColor: const Color(0xFF6E7A6B),
      upcomingPathBorderColor: const Color(0xFF3B443A),
      shadowColor: const Color(0x40000000),
      pathBorderWidth: 12,
      pathInnerStrokeWidth: 7,

      // 2.0.0: a translucent wash the chunk painter lays over background and
      // path, under the node widgets. Only the demo's own realms set one, so
      // toggling "Host realms" shows the difference.
      ambientTint: tint,

      // 2.0.0: art keys the package hands back untouched. A real host would
      // put asset paths here; this demo puts a letter, to make the point that
      // the package never looks inside.
      assets: {emblemKey: emblem},
    );
  }
}
0
likes
160
points
204
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package for building responsive world-map level progression UIs with custom host-provided assets.

Repository (GitHub)
View/report issues
Contributing

License

MIT (license)

Dependencies

equatable, fast_noise, flutter

More

Packages that depend on saga_map