fifty_world_engine 0.1.1 copy "fifty_world_engine: ^0.1.1" to clipboard
fifty_world_engine: ^0.1.1 copied to clipboard

Fifty Flutter Kit world engine - Flame-based interactive grid world rendering for Flutter games

example/lib/main.dart

/// Tactical Skirmish Sandbox
///
/// A lightweight 2-player hot-seat tactical game showcasing 19/21
/// fifty_world_engine v2 features: tile grid, overlays, entity decorators,
/// animation queue, A* pathfinding, BFS movement range, and more.
library;

import 'package:fifty_world_engine/fifty_world_engine.dart';
import 'package:flame/components.dart' show Vector2;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// ============================================================
// GAME DATA
// ============================================================

const _grass = TileType(
  id: 'grass',
  asset: 'tiles/tile_light.png',
  color: Color(0xFF4CAF50),
  walkable: true,
  movementCost: 1.0,
);
const _forest = TileType(
  id: 'forest',
  asset: 'tiles/tile_dark.png',
  color: Color(0xFF2E7D32),
  walkable: true,
  movementCost: 2.0,
);
const _water = TileType(
  id: 'water',
  asset: 'tiles/tile_trap.png',
  color: Color(0xFF1565C0),
  walkable: false,
);
const _wall = TileType(
  id: 'wall',
  asset: 'tiles/tile_obstacle.png',
  color: Color(0xFF5D4037),
  walkable: false,
);

/// Unit stats for one game piece.
class UnitData {
  final String id;
  final String name;
  final String team; // 'blue' | 'red'
  final String unitType; // 'warrior' | 'archer' | 'mage'
  int hp;
  final int maxHp;
  final int atk;
  final int moveRange;
  final int atkRange;
  GridPosition position;

  UnitData({
    required this.id,
    required this.name,
    required this.team,
    required this.unitType,
    required this.hp,
    required this.maxHp,
    required this.atk,
    required this.moveRange,
    required this.atkRange,
    required this.position,
  });

  String get label => unitType == 'warrior'
      ? 'W'
      : unitType == 'archer'
          ? 'A'
          : 'M';

  String get spritePath {
    final prefix = isBlue ? 'player' : 'enemy';
    final suffix = unitType == 'warrior' ? 'commander' : unitType;
    return 'units/${prefix}_$suffix.png';
  }

  bool get isBlue => team == 'blue';

  UnitData clone() => UnitData(
        id: id,
        name: name,
        team: team,
        unitType: unitType,
        hp: hp,
        maxHp: maxHp,
        atk: atk,
        moveRange: moveRange,
        atkRange: atkRange,
        position: GridPosition(position.x, position.y),
      );
}

List<UnitData> _initialUnits() => [
      // Blue team
      UnitData(
          id: 'blue_warrior',
          name: 'Blue Warrior',
          team: 'blue',
          unitType: 'warrior',
          hp: 100,
          maxHp: 100,
          atk: 25,
          moveRange: 3,
          atkRange: 1,
          position: const GridPosition(1, 2)),
      UnitData(
          id: 'blue_archer',
          name: 'Blue Archer',
          team: 'blue',
          unitType: 'archer',
          hp: 70,
          maxHp: 70,
          atk: 20,
          moveRange: 3,
          atkRange: 2,
          position: const GridPosition(1, 4)),
      UnitData(
          id: 'blue_mage',
          name: 'Blue Mage',
          team: 'blue',
          unitType: 'mage',
          hp: 60,
          maxHp: 60,
          atk: 35,
          moveRange: 2,
          atkRange: 2,
          position: const GridPosition(1, 6)),
      // Red team
      UnitData(
          id: 'red_warrior',
          name: 'Red Warrior',
          team: 'red',
          unitType: 'warrior',
          hp: 100,
          maxHp: 100,
          atk: 25,
          moveRange: 3,
          atkRange: 1,
          position: const GridPosition(8, 1)),
      UnitData(
          id: 'red_archer',
          name: 'Red Archer',
          team: 'red',
          unitType: 'archer',
          hp: 70,
          maxHp: 70,
          atk: 20,
          moveRange: 3,
          atkRange: 2,
          position: const GridPosition(8, 3)),
      UnitData(
          id: 'red_mage',
          name: 'Red Mage',
          team: 'red',
          unitType: 'mage',
          hp: 60,
          maxHp: 60,
          atk: 35,
          moveRange: 2,
          atkRange: 2,
          position: const GridPosition(8, 5)),
    ];

TileGrid _buildMap() {
  final grid = TileGrid(width: 10, height: 8);
  grid.fill(_grass);
  // Forests
  grid.fillRect(const GridPosition(3, 1), 2, 2, _forest);
  grid.fillRect(const GridPosition(6, 4), 2, 2, _forest);
  // Water
  grid.fillRect(const GridPosition(4, 3), 2, 2, _water);
  // Walls
  for (final p in [
    const GridPosition(0, 0),
    const GridPosition(9, 7),
    const GridPosition(5, 0),
    const GridPosition(4, 7),
  ]) {
    grid.setTile(p, _wall);
  }
  return grid;
}

// ============================================================
// GAME STATE
// ============================================================

class GameState {
  List<UnitData> units;
  String? selectedUnitId;
  bool isBlueTeam;
  bool hasMoved;
  bool hasAttacked;
  String? winner;

  /// Units that have already acted (moved/attacked) this turn.
  Set<String> usedUnits;

  /// Positions currently shown as valid moves.
  Set<GridPosition> moveTargets;

  /// Positions currently shown as attack range.
  Set<GridPosition> attackTargets;

  GameState({required this.units})
      : isBlueTeam = true,
        hasMoved = false,
        hasAttacked = false,
        selectedUnitId = null,
        winner = null,
        usedUnits = {},
        moveTargets = {},
        attackTargets = {};

  UnitData? get selectedUnit =>
      selectedUnitId == null ? null : _unitById(selectedUnitId!);

  UnitData? _unitById(String id) {
    for (final u in units) {
      if (u.id == id) return u;
    }
    return null;
  }

  UnitData? unitAt(GridPosition pos) {
    for (final u in units) {
      if (u.position == pos) return u;
    }
    return null;
  }

  Set<GridPosition> get occupiedPositions =>
      units.map((u) => u.position).toSet();
}

// ============================================================
// APP
// ============================================================

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.landscapeLeft,
    DeviceOrientation.landscapeRight,
  ]);
  FiftyAssetLoader.registerAssets([
    'units/player_commander.png',
    'units/player_archer.png',
    'units/player_mage.png',
    'units/enemy_commander.png',
    'units/enemy_archer.png',
    'units/enemy_mage.png',
    'tiles/tile_light.png',
    'tiles/tile_dark.png',
    'tiles/tile_obstacle.png',
    'tiles/tile_trap.png',
  ]);
  runApp(const TacticalSkirmishApp());
}

/// Root widget with dark theme.
class TacticalSkirmishApp extends StatelessWidget {
  const TacticalSkirmishApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Tactical Skirmish Sandbox',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark(useMaterial3: true),
      home: const TacticalSkirmishPage(),
    );
  }
}

// ============================================================
// GAME PAGE
// ============================================================

/// The main game screen.
class TacticalSkirmishPage extends StatefulWidget {
  const TacticalSkirmishPage({super.key});

  @override
  State<TacticalSkirmishPage> createState() => _TacticalSkirmishPageState();
}

class _TacticalSkirmishPageState extends State<TacticalSkirmishPage> {
  late FiftyWorldController _controller;
  late TileGrid _grid;
  late GameState _state;
  bool _decoratorsApplied = false;
  bool _isMoving = false;

  static const _blueColor = Color(0xFF2196F3);
  static const _redColor = Color(0xFFF44336);

  @override
  void initState() {
    super.initState();
    _controller = FiftyWorldController();
    _grid = _buildMap();
    _state = GameState(units: _initialUnits());
    // Apply decorators after the game engine has spawned the entities.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Future.delayed(const Duration(milliseconds: 300), _setupDecorators);
    });
  }

  // --- Entity building ---

  List<FiftyWorldEntity> _buildEntities() {
    return _state.units.map((u) {
      return FiftyWorldEntity(
        id: u.id,
        type: 'character',
        asset: u.spritePath,
        gridPosition: Vector2(u.position.x.toDouble(), u.position.y.toDouble()),
        blockSize: FiftyBlockSize(1, 1),
      );
    }).toList();
  }

  // --- Decorator setup ---

  void _setupDecorators() {
    if (_decoratorsApplied) return;
    _decoratorsApplied = true;
    for (final unit in _state.units) {
      _controller.setTeamColor(
          unit.id, unit.isBlue ? _blueColor : _redColor);
      _controller.updateHP(unit.id, unit.hp / unit.maxHp);
      _controller.addStatusIcon(unit.id, unit.label);
    }
    // Zoom out to fit grid, then center camera on entities
    _controller.zoomOut();
    _controller.zoomOut();
    _controller.centerMap();
  }

  // --- Tap handlers ---

  // Entity taps fire on TapDown (press) while tile taps fire on TapUp
  // (release). Using both causes race conditions when the finger shifts
  // between press and release, so all game logic is handled in _onTileTap.
  void _onEntityTap(FiftyWorldEntity entity) {}

  void _onTileTap(GridPosition pos) {
    if (_state.winner != null) return;
    if (_isMoving) return;
    if (_controller.isAnimating || _controller.inputManager.isBlocked) return;

    // Own unit at this tile?
    final unitHere = _state.unitAt(pos);
    if (unitHere != null) {
      final isOwn = (unitHere.isBlue && _state.isBlueTeam) ||
          (!unitHere.isBlue && !_state.isBlueTeam);
      if (isOwn) {
        // Don't allow selecting units that already acted this turn
        if (!_state.usedUnits.contains(unitHere.id)) {
          _selectUnit(unitHere);
        }
        return;
      }
      // Enemy at this tile - check attack range
      if (_state.attackTargets.contains(pos)) {
        _attackUnit(unitHere);
        return;
      }
    }

    // Move to this tile?
    if (_state.moveTargets.contains(pos) && !_state.hasMoved) {
      _moveUnit(pos);
      return;
    }

    // Deselect
    _deselectUnit();
  }

  // --- Selection ---

  void _selectUnit(UnitData unit) {
    // Deselect previous
    if (_state.selectedUnitId != null) {
      _controller.setSelected(_state.selectedUnitId!, selected: false);
    }
    _controller.clearHighlights();

    setState(() {
      _state.selectedUnitId = unit.id;
      _state.hasMoved = false;
      _state.hasAttacked = false;
      _state.moveTargets = {};
      _state.attackTargets = {};
    });

    // Selection ring
    _controller.setSelected(unit.id, selected: true);
    _controller.setSelection(unit.position);

    // Movement range (BFS)
    if (!_state.hasMoved) {
      final blocked = _state.occupiedPositions..remove(unit.position);
      final reachable = _controller.getMovementRange(
        unit.position,
        budget: unit.moveRange.toDouble(),
        grid: _grid,
        blocked: blocked,
      )..remove(unit.position);

      if (reachable.isNotEmpty) {
        _controller.highlightTiles(
            reachable.toList(),
            HighlightStyle.custom(
              color: const Color(0xFF00BCD4),
              opacity: 0.55,
              group: 'validMoves',
            ));
      }
      setState(() => _state.moveTargets = reachable);
    }

    // Attack range
    _showAttackRange(unit);
  }

  void _showAttackRange(UnitData unit) {
    final atkPositions = <GridPosition>{};
    for (final pos in _grid.allPositions) {
      if (pos.manhattanDistanceTo(unit.position) <= unit.atkRange &&
          pos != unit.position) {
        atkPositions.add(pos);
      }
    }
    if (atkPositions.isNotEmpty) {
      _controller.highlightTiles(
          atkPositions.toList(),
          HighlightStyle.custom(
            color: const Color(0xFFF44336),
            opacity: 0.55,
            group: 'attackRange',
          ));
    }
    setState(() => _state.attackTargets = atkPositions);
  }

  void _deselectUnit() {
    if (_state.selectedUnitId != null) {
      _controller.setSelected(_state.selectedUnitId!, selected: false);
    }
    _controller.clearHighlights();
    setState(() {
      _state.selectedUnitId = null;
      _state.moveTargets = {};
      _state.attackTargets = {};
    });
  }

  // --- Movement ---

  void _moveUnit(GridPosition target) {
    final unit = _state.selectedUnit;
    if (unit == null) return;
    if (!_state.moveTargets.contains(target)) return;

    _isMoving = true;

    // A* pathfinding to compute the optimal route
    final blocked = _state.occupiedPositions.difference({unit.position});
    final path = _controller.findPath(
      unit.position,
      target,
      grid: _grid,
      blocked: blocked,
    );
    if (path == null || path.length < 2) {
      _isMoving = false;
      return;
    }

    _controller.clearHighlights(group: 'validMoves');

    // Queue step-by-step movement along the A* path
    for (int i = 1; i < path.length; i++) {
      final step = path[i];
      final isLast = i == path.length - 1;
      _controller.queueAnimation(AnimationEntry(
        execute: () async {
          final entity = _controller.getEntityById(unit.id);
          if (entity == null) return;
          _controller.move(entity, step.x.toDouble(), step.y.toDouble());
          await Future<void>.delayed(const Duration(milliseconds: 350));
        },
        onComplete: isLast
            ? () {
                setState(() {
                  unit.position = target;
                  _state.hasMoved = true;
                  _state.usedUnits.add(unit.id);
                  _state.moveTargets = {};
                  _state.attackTargets = {};
                });
                _isMoving = false;
                _deselectUnit();
                _checkAutoTurnSwitch();
              }
            : null,
      ));
    }
  }

  // --- Attack ---

  void _attackUnit(UnitData enemy) {
    final attacker = _state.selectedUnit;
    if (attacker == null) return;
    if (_state.hasAttacked) return;

    final attackerComp = _controller.getComponentById(attacker.id);
    if (attackerComp == null || attackerComp is! FiftyMovableComponent) return;

    final damage = attacker.atk;
    enemy.hp = (enemy.hp - damage).clamp(0, enemy.maxHp);

    _controller.clearHighlights();

    // Attack animation
    _controller.queueAnimation(AnimationEntry.timed(
      action: () => attackerComp.attack(),
      duration: const Duration(milliseconds: 250),
    ));

    // Damage popup + HP update
    _controller.queueAnimation(AnimationEntry.timed(
      action: () {
        _controller.showFloatingText(
          enemy.position,
          '-$damage',
          color: const Color(0xFFFFFF00),
          fontSize: 24,
        );
        _controller.updateHP(
          enemy.id,
          enemy.hp / enemy.maxHp,
          color: enemy.hp / enemy.maxHp > 0.5
              ? const Color(0xFF4CAF50)
              : const Color(0xFFF44336),
        );
      },
      duration: const Duration(milliseconds: 400),
    ));

    // Death or end turn
    if (enemy.hp <= 0) {
      final enemyComp = _controller.getComponentById(enemy.id);
      if (enemyComp is FiftyMovableComponent) {
        _controller.queueAnimation(AnimationEntry(
          execute: () async {
            enemyComp.die();
            await Future<void>.delayed(const Duration(milliseconds: 600));
          },
          onComplete: () {
            final enemyEntity = _controller.getEntityById(enemy.id);
            if (enemyEntity != null) {
              _controller.removeEntity(enemyEntity);
            }
            setState(() {
              _state.units.removeWhere((u) => u.id == enemy.id);
              _state.hasAttacked = true;
            });
            _checkWin();
            if (_state.winner == null) _endTurn();
          },
        ));
      }
    } else {
      _controller.queueAnimation(AnimationEntry.timed(
        action: () {},
        duration: const Duration(milliseconds: 100),
        onComplete: () {
          setState(() => _state.hasAttacked = true);
          _endTurn();
        },
      ));
    }
  }

  // --- Turn management ---

  void _endTurn() {
    if (_state.selectedUnitId != null) {
      _controller.setSelected(_state.selectedUnitId!, selected: false);
    }
    _controller.clearHighlights();
    setState(() {
      _state.selectedUnitId = null;
      _state.isBlueTeam = !_state.isBlueTeam;
      _state.hasMoved = false;
      _state.hasAttacked = false;
      _state.usedUnits = {};
      _state.moveTargets = {};
      _state.attackTargets = {};
    });
  }

  void _checkWin() {
    final hasBlue = _state.units.any((u) => u.isBlue);
    final hasRed = _state.units.any((u) => !u.isBlue);
    if (!hasBlue) {
      setState(() => _state.winner = 'Red');
    } else if (!hasRed) {
      setState(() => _state.winner = 'Blue');
    }
  }

  void _checkAutoTurnSwitch() {
    final friendlyUnits = _state.units
        .where((u) => u.isBlue == _state.isBlueTeam)
        .toList();
    if (_state.usedUnits.length >= friendlyUnits.length) {
      final wasBlueTeam = _state.isBlueTeam;
      Future.delayed(const Duration(milliseconds: 500), () {
        if (mounted &&
            _state.winner == null &&
            _state.isBlueTeam == wasBlueTeam) {
          _endTurn();
        }
      });
    }
  }

  void _restart() {
    _controller.clearHighlights();
    _controller.cancelAnimations();
    _controller.clear();
    _decoratorsApplied = false;
    _isMoving = false;
    setState(() {
      _state = GameState(units: _initialUnits());
    });
    _controller.addEntities(_buildEntities());
    Future.delayed(const Duration(milliseconds: 300), _setupDecorators);
  }

  // --- Build ---

  @override
  Widget build(BuildContext context) {
    final teamName = _state.isBlueTeam ? 'Blue' : 'Red';
    final teamColor = _state.isBlueTeam ? _blueColor : _redColor;

    return Scaffold(
      appBar: AppBar(
        title: Row(
          children: [
            Container(
              width: 14,
              height: 14,
              decoration: BoxDecoration(color: teamColor, shape: BoxShape.circle),
            ),
            const SizedBox(width: 8),
            Text('$teamName Team\'s Turn'),
          ],
        ),
        actions: [
          if (_state.winner == null)
            TextButton(
              onPressed: () => _endTurn(),
              child: Text('End Turn',
                  style: TextStyle(
                      color: Theme.of(context).colorScheme.onSurface)),
            ),
        ],
      ),
      body: Stack(
        children: [
          FiftyWorldWidget(
            grid: _grid,
            controller: _controller,
            initialEntities: _buildEntities(),
            onEntityTap: _onEntityTap,
            onTileTap: _onTileTap,
          ),
          if (_state.winner != null) _buildWinOverlay(),
        ],
      ),
    );
  }

  Widget _buildWinOverlay() {
    final color = _state.winner == 'Blue' ? _blueColor : _redColor;
    final colorScheme = Theme.of(context).colorScheme;
    return Container(
      color: colorScheme.scrim.withValues(alpha: 0.54),
      child: Center(
        child: Card(
          color: colorScheme.surfaceContainerHigh,
          child: Padding(
            padding: const EdgeInsets.all(32),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text(
                  '${_state.winner} Team Wins!',
                  style: TextStyle(fontSize: 32, color: color, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 16),
                FilledButton.icon(
                  onPressed: _restart,
                  icon: const Icon(Icons.refresh),
                  label: const Text('Play Again'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
0
likes
145
points
98
downloads
screenshot

Publisher

verified publisherfifty.dev

Weekly Downloads

Fifty Flutter Kit world engine - Flame-based interactive grid world rendering for Flutter games

Homepage
Repository (GitHub)
View/report issues

Topics

#flutter #game #tilemap #flame

Documentation

API reference

License

MIT (license)

Dependencies

flame, flutter, flutter_web_plugins, logging, plugin_platform_interface, web

More

Packages that depend on fifty_world_engine

Packages that implement fifty_world_engine