flutter3d_game 1.0.0-rc.1
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.
/// 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(),
},
),
],
),
);
}
}