flutter3d_game 1.0.0-rc.1 copy "flutter3d_game: ^1.0.0-rc.1" to clipboard
flutter3d_game: ^1.0.0-rc.1 copied to clipboard

What a game on flutter3d adds to an application: keyboard, mouse, gamepad and touch input, the run being played, the settings and saves, and its actors and fixtures drawn.

example/lib/main.dart

/// A level you can walk around, which is what a new project starts as.
///
///     flutter run -d macos
///
/// **This is a seed, not a game.** It is one [Flutter3dView], which opens the
/// device, makes the renderer, runs the engine's loop and owns focus and the
/// lifecycle. Into that loop it installs [WalkGenre], a genre of its own: a
/// body that walks, looks and jumps through the level in `assets/levels/`,
/// steered by an [ActionMap] and saying on the event bus when it lands. What
/// it deliberately does not do is anything a real genre does: nothing to
/// fight, nothing to collect, no doors that open, no score, no menu, no
/// saving. Those live in `flutter3d_game_shooter` and
/// `flutter3d_game_platformer`, each a `GenrePlugin` handed to the view's
/// `plugins` in place of this one.
///
/// It is a real application in this repository as well as a template, so that
/// it is analysed and compiled by CI. **A `main.dart` that is only ever a
/// string in a scaffolder is one that stops compiling six months later and
/// nobody finds out until somebody creates a project.**
library;

import 'package:flutter/material.dart';
import 'package:flutter3d_game/flutter3d_game.dart';
import 'package:flutter3d_game_ui/theme.dart';
import 'package:flutter3d_sim/flutter3d_sim.dart';

/// The level this project opens with.
const String levelAsset = String.fromEnvironment(
  'level',
  defaultValue: 'assets/levels/first.json',
);

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

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

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'A level',
    debugShowCheckedModeBanner: false,
    localizationsDelegates: const <LocalizationsDelegate<Object>>[
      Flutter3dGameLocalizations.delegate,
      DefaultMaterialLocalizations.delegate,
      DefaultWidgetsLocalizations.delegate,
    ],
    home: const LevelScreen(),
  );
}

/// The body came down onto the ground, at [speed] metres a second.
///
/// The one event this seed's genre publishes, declared with its codec so a
/// run's event digest folds in what it carries; a screen hears it on the
/// frame channel.
final class Landed extends BusEvent {
  const Landed(this.speed);

  /// The name it is declared under, prefixed with the genre's.
  static const String eventName = 'flutter3d_game_example.landed';

  /// How it is written down and read back.
  static const EventCodec<Landed> codec = EventCodec<Landed>.of(
    encode: _encode,
    decode: _decode,
  );

  static Object? _encode(Landed event) => <String, Object?>{
    'speed': event.speed,
  };

  static Landed? _decode(EventDecodeContext context) => switch (context.data) {
    {'speed': final num speed} => Landed(speed.toDouble()),
    _ => null,
  };

  /// How fast it was falling, in metres a second.
  final double speed;

  @override
  String get name => eventName;

  @override
  void digestInto(EventDigestSink sink) => sink.add(speed);
}

/// The seed's own genre: a [LevelWalk] stepped in the loop's physics phase,
/// the way `flutter3d_game_platformer` steps its runner.
///
/// Its run is the walk of the level that is up, set as levels come and go;
/// its input is the loop's, written by the keyboard through the [ActionMap].
final class WalkGenre extends GenrePlugin<LevelWalk> {
  WalkGenre({required this.input});

  /// The plugin's id.
  static const String id = 'flutter3d_game_example.walk';

  /// The name of the genre's step system.
  static const String stepSystem = 'flutter3d_game_example.step';

  /// What the walk reads: the loop's input.
  final InputState input;

  bool _wasGrounded = true;

  @override
  String get systemName => stepSystem;

  @override
  PluginManifest get manifest => const PluginManifest(
    id: id,
    apiVersion: PluginApiVersion.current,
    touches: PluginTouches.simulation,
    description: 'A body that walks, looks and jumps through a level.',
  );

  @override
  void declareEvents(EventRegistry events) {
    events.declare<Landed>(
      Landed.eventName,
      description: 'The body came down onto the ground.',
      codec: Landed.codec,
    );
  }

  @override
  void stepSimulation(GenreStepContext<LevelWalk> step) {
    final simulation = step.simulation;
    final context = step.context;
    final falling = -simulation.body.velocity.y;
    // A jump if one was pressed, a walk where the keys point turned by where
    // the head is, a run while sprint is held.
    simulation.step(context.dt, input);
    final grounded = simulation.body.isGrounded;
    if (grounded && !_wasGrounded) context.publish(Landed(falling));
    _wasGrounded = grounded;
  }

  /// The body, and which way the head is turned, in the loop's snapshots.
  @override
  Snapshot captureSimulation(LevelWalk simulation) =>
      Snapshot(<String, Object?>{
        'body': simulation.body.save(),
        'yaw': simulation.yaw,
        'pitch': simulation.pitch,
        'grounded': _wasGrounded,
      });

  @override
  void restoreSimulation(GenreRestoreContext<LevelWalk> restore) {
    final simulation = restore.simulation;
    final state = restore.state;
    if (state.data['body'] case final Map<String, Object?> body) {
      simulation.body.restore(body);
    }
    simulation
      ..yaw = state.data.number('yaw')
      ..pitch = state.data.number('pitch');
    _wasGrounded = state.data['grounded'] == true;
  }
}

/// A level that is up: what was loaded, and the walk through it.
final class WalkableLevel {
  const WalkableLevel(this.loaded, this.walk);

  final LoadedLevel loaded;
  final LevelWalk walk;
}

/// Reads [asset] and builds everything it takes to walk around in it, on
/// [device]: [RunPlaying] with the level, or [RunFailed] with why not.
///
/// **A level that will not read used to be a black screen for ever**: the
/// load caught its own throw and printed it, which is a line in a console
/// nobody playing the game can see. A test hands in a `CpuDevice` and drives
/// the whole load without a window.
Future<RunStatus<WalkableLevel>> openLevel(
  GraphicsDevice device, {
  String asset = levelAsset,
}) async {
  try {
    // Read first, build second: the registry is made out of what the
    // document happens to name, which is not knowable before reading it.
    final level = await loadLevelAsset(asset);
    final loaded = await LevelLoader().build(
      level,
      device: device,
      // Every type the document names is accepted and none is given a
      // meaning, until there is something to spawn them into.
      registry: openRegistryFor(level),
    );
    _addParts(level, loaded, device);
    final spawn = LevelWalk.spawnIn(level);
    return RunPlaying<WalkableLevel>(
      asset,
      WalkableLevel(
        loaded,
        LevelWalk(world: loaded.collision, at: spawn.at, yaw: spawn.yaw),
      ),
    );
  } on Object catch (error) {
    return RunFailed<WalkableLevel>(asset, error);
  }
}

/// [level]'s own `part` entities, as plain boxed [MeshNode]s named after the
/// entity that placed them.
///
/// **A brush carries no name, and that is the whole reason this exists.** A
/// level's geometry arrives as brushes, which the loader bakes into one
/// surface per material with nothing to address a single piece by; anything
/// that wants to show one piece and hide another, or swap what one piece is
/// made of while the scene is running, has nothing to hold. A `part` entity
/// is that handle: it carries a name, a size and a material, and it becomes a
/// mesh node that answers to the name.
///
/// **Not collision.** A part added here is scenery: something to look at and
/// to swap, not something to walk into.
void _addParts(Level level, LoadedLevel loaded, GraphicsDevice device) {
  final meshes = SharedMeshes(device);
  for (final entity in level.entities) {
    if (entity.type != 'part') continue;
    final name = entity.name;
    if (name == null) continue;
    final materialName = entity.string('material');
    final source = materialName == null ? null : level.materials[materialName];
    final material = LevelLoader.materialFrom(
      source ?? LevelMaterial(),
      const <String, TextureHandle?>{},
      name: materialName,
    );
    final size = entity.vector('size') ?? Vector3.all(1.0);
    loaded.scene.add(
      MeshNode(meshes.box(size), material, name: name)
        ..setPositionFrom(entity.position),
    );
  }
}

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

  @override
  State<LevelScreen> createState() => _LevelScreenState();
}

class _LevelScreenState extends State<LevelScreen> {
  /// What the loop reads, written by the keyboard.
  final InputState _input = InputState();

  /// The keyboard, through the default action map: WASD, shift to run,
  /// space to jump. A settings screen rebinds the same map.
  late final DesktopInput _keys = DesktopInput(
    state: _input,
    actions: DesktopInput.defaultActionMap(),
  );

  late final WalkGenre _genre = WalkGenre(input: _input);

  final CameraNode _eye = CameraNode(name: 'eye');

  /// What the screen draws over the view: loading, playing or why not.
  final ValueNotifier<RunStatus<WalkableLevel>> _status =
      ValueNotifier<RunStatus<WalkableLevel>>(
        const RunLoading<WalkableLevel>(asset: levelAsset),
      );

  /// The last landing, heard on the frame channel.
  final ValueNotifier<double?> _landed = ValueNotifier<double?>(null);

  Offset? _dragged;

  Future<void> _created(Flutter3dEngine engine) async {
    engine.loop.events.onFrame<Landed>(
      'seed.screen',
      (Delivered<Landed> delivered) => _landed.value = delivered.event.speed,
    );
    final status = await openLevel(engine.device);
    if (!mounted) return;
    if (status case RunPlaying<WalkableLevel>(:final level)) {
      // The level's scene is drawn from the next frame, the eye moved into
      // it, and the genre steps its walk.
      engine.scene = level.loaded.scene;
      _genre.simulation = level.walk;
    }
    _status.value = status;
  }

  void _frame(EngineFrame f) {
    // At eye height, looking where the mouse has been dragged.
    _genre.simulation?.placeCamera(f.engine.camera);
  }

  @override
  void dispose() {
    _keys.dispose().ignore();
    _status.dispose();
    _landed.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: const Color(0xFF14161A),
      body: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          Listener(
            onPointerDown: (PointerDownEvent event) =>
                _dragged = event.localPosition,
            onPointerMove: (PointerMoveEvent event) {
              final from = _dragged;
              if (from == null) return;
              final by = event.localPosition - from;
              _dragged = event.localPosition;
              _genre.simulation?.look(by.dx, by.dy);
            },
            onPointerUp: (_) => _dragged = null,
            child: Flutter3dView(
              camera: _eye,
              loop: LoopSettings(input: _input),
              plugins: PluginSettings(plugins: <Flutter3dPlugin>[_genre]),
              autofocus: true,
              onKeyEvent: _keys.handleKeyEvent,
              onCreated: (Flutter3dEngine engine) => _created(engine).ignore(),
              onFrame: _frame,
              view: ViewSettings(
                placeholder: const Center(child: CircularProgressIndicator()),
                failure: (Object error) => DidNotStart(
                  error,
                  background: const Color(0xFF14161A),
                  foreground: const Color(0xFFFF8A80),
                ),
              ),
            ),
          ),
          ValueListenableBuilder<RunStatus<WalkableLevel>>(
            valueListenable: _status,
            builder:
                (BuildContext context, RunStatus<WalkableLevel> status, _) =>
                    switch (status) {
                      RunLoading() => const SizedBox.shrink(),
                      RunFailed(:final error) => DidNotStart(
                        error,
                        background: const Color(0xFF14161A),
                        foreground: const Color(0xFFFF8A80),
                      ),
                      RunPlaying() => Align(
                        alignment: Alignment.bottomCenter,
                        child: ColoredBox(
                          color: const Color(0xCC0E1013),
                          child: Padding(
                            padding: const EdgeInsets.symmetric(
                              horizontal: 12,
                              vertical: 6,
                            ),
                            child: Text(
                              'W A S D walk · shift runs · space jumps · '
                              'drag to look',
                              style: const TextStyle(
                                color: Color(0xFF9AA4B2),
                                fontSize: 12,
                              ),
                            ),
                          ),
                        ),
                      ),
                      _ => const SizedBox.shrink(),
                    },
          ),
        ],
      ),
    );
  }
}