From 8c88afbf594aeef19e6d647c29d939c888511fc0 Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Thu, 1 Oct 2026 18:02:51 +0300 Subject: [PATCH 1/4] feat: Add flame_flutter3d, a bridge to the flutter3d 3D engine Moves flame_flutter3d 0.8.4 from the flutter3d repository into the Flame monorepo as 0.9.0-dev.0 and ports it to Flame 2.0: synchronous add, HasGameRef, MouseMoveCallbacks, list-based collision points, the forge2d on Box2D v3, tiled 0.12, and CustomTraversal in place of the updateTree overrides that write a component's transform after its effects. Adds a bridge package page covering the shared loop, the bridge plane, post-processing through HasFlutter3d.renderSettings, and WebGL2/WebGPU builds. --- .github/.cspell/flame_dictionary.txt | 8 + .github/.cspell/gamedev_dictionary.txt | 16 +- .github/.cspell/people_usernames.txt | 1 + .github/.cspell/words_dictionary.txt | 19 + doc/bridge_packages/bridge_packages.md | 8 + .../flame_flutter3d/flame_flutter3d.md | 150 ++++ packages/flame_flutter3d/CHANGELOG.md | 718 ++++++++++++++++++ packages/flame_flutter3d/LICENSE | 21 + packages/flame_flutter3d/README.md | 163 ++++ .../flame_flutter3d/analysis_options.yaml | 11 + .../example/analysis_options.yaml | 15 + .../flame_flutter3d/example/lib/main.dart | 257 +++++++ packages/flame_flutter3d/example/pubspec.yaml | 46 ++ .../example/test/hybrid_test.dart | 51 ++ .../flame_flutter3d/lib/flame_flutter3d.dart | 74 ++ .../animation/model_animation_component.dart | 118 +++ .../lib/src/camera/camera_sync_component.dart | 58 ++ .../src/camera/camera_sync_controller.dart | 241 ++++++ .../lib/src/camera/chase_camera.dart | 100 +++ .../lib/src/camera/projected_viewfinder.dart | 117 +++ .../lib/src/camera/view_camera.dart | 74 ++ .../lib/src/debug/hitboxes3d.dart | 66 ++ .../lib/src/ecs/actor_component.dart | 193 +++++ .../lib/src/ecs/actor_system_component.dart | 171 +++++ .../src/ecs/instanced_actor_component.dart | 214 ++++++ .../lib/src/host/bridge_clock.dart | 47 ++ .../lib/src/host/bridge_priority.dart | 60 ++ .../lib/src/host/flutter3d_flame_widget.dart | 529 +++++++++++++ .../lib/src/host/has_fixed_step.dart | 191 +++++ .../lib/src/host/has_flutter3d.dart | 277 +++++++ .../lib/src/host/step_clock.dart | 30 + .../lib/src/host/transparent_flame_game.dart | 25 + .../lib/src/host/updates_at_root.dart | 68 ++ .../lib/src/input/flame_input_bridge.dart | 663 ++++++++++++++++ .../flame_flutter3d/lib/src/input/taps3d.dart | 209 +++++ .../src/particles/particles3d_component.dart | 112 +++ .../src/physics/character_body_component.dart | 129 ++++ .../lib/src/physics/collider_registry.dart | 130 ++++ .../lib/src/physics/collision_bridge.dart | 220 ++++++ .../src/physics/kinematic_body_component.dart | 99 +++ .../src/physics/physics_step_component.dart | 135 ++++ .../lib/src/physics/rigid_body_component.dart | 172 +++++ .../lib/src/transform/billboard_atlas.dart | 148 ++++ .../lib/src/transform/bridge_space.dart | 57 ++ .../lib/src/transform/bridged3d.dart | 70 ++ .../lib/src/transform/flame_pose.dart | 93 +++ .../instanced_object3d_component.dart | 249 ++++++ .../lib/src/transform/node3d_component.dart | 259 +++++++ .../lib/src/transform/object3d_component.dart | 463 +++++++++++ .../lib/src/transform/plane.dart | 202 +++++ .../lib/src/transform/projector.dart | 175 +++++ .../transform/sprite_billboard_component.dart | 259 +++++++ .../lib/src/world/atmosphere_component.dart | 56 ++ .../lib/src/world/cell_grid_component.dart | 284 +++++++ .../lib/src/world/chunk_streamer.dart | 57 ++ .../src/world/fixture_visuals_component.dart | 38 + .../lib/src/world/grid_mover.dart | 171 +++++ .../lib/src/world/tiled_world.dart | 137 ++++ .../lib/src/world/trail_component.dart | 118 +++ .../lib/src/world/wrap_space.dart | 421 ++++++++++ packages/flame_flutter3d/pubspec.yaml | 90 +++ .../test/actor_component_test.dart | 157 ++++ .../test/actor_system_component_test.dart | 147 ++++ .../test/atmosphere_component_test.dart | 49 ++ .../test/bridge_clock_test.dart | 57 ++ .../test/camera_and_projection_test.dart | 207 +++++ .../test/camera_sync_component_test.dart | 148 ++++ .../test/camera_sync_controller_test.dart | 265 +++++++ .../test/cell_grid_component_test.dart | 68 ++ .../test/character_body_component_test.dart | 107 +++ .../test/chunk_streamer_test.dart | 54 ++ packages/flame_flutter3d/test/co_op_test.dart | 371 +++++++++ .../test/collider_registry_test.dart | 306 ++++++++ .../test/collision_bridge_test.dart | 289 +++++++ .../test/curvilinear_space_test.dart | 50 ++ .../test/flame_input_bridge_test.dart | 124 +++ .../test/flutter3d_flame_widget_test.dart | 265 +++++++ .../test/follows_forge2d_test.dart | 90 +++ .../flame_flutter3d/test/grid_mover_test.dart | 180 +++++ .../test/has_fixed_step_test.dart | 184 +++++ .../test/has_flutter3d_test.dart | 256 +++++++ .../test/input_step_pointer_test.dart | 201 +++++ .../instanced_object3d_component_test.dart | 166 ++++ .../test/kinematic_body_component_test.dart | 105 +++ .../test/model_animation_test.dart | 101 +++ .../test/node3d_component_test.dart | 137 ++++ .../test/object3d_component_test.dart | 110 +++ .../test/object3d_transform_test.dart | 443 +++++++++++ .../test/owned_meshes_test.dart | 84 ++ .../test/particles3d_component_test.dart | 113 +++ .../test/physics_step_component_test.dart | 191 +++++ packages/flame_flutter3d/test/plane_test.dart | 109 +++ .../flame_flutter3d/test/players_test.dart | 93 +++ .../test/projected_viewfinder_test.dart | 178 +++++ .../test/refinements_test.dart | 187 +++++ .../test/rigid_body_component_test.dart | 197 +++++ .../test/sprite_billboard_test.dart | 264 +++++++ .../flame_flutter3d/test/taps3d_test.dart | 270 +++++++ .../test/tiled_world_test.dart | 103 +++ .../test/trail_component_test.dart | 91 +++ .../test/transparent_flame_game_test.dart | 28 + .../flame_flutter3d/test/wrap_space_test.dart | 208 +++++ 102 files changed, 16105 insertions(+), 1 deletion(-) create mode 100644 doc/bridge_packages/flame_flutter3d/flame_flutter3d.md create mode 100644 packages/flame_flutter3d/CHANGELOG.md create mode 100644 packages/flame_flutter3d/LICENSE create mode 100644 packages/flame_flutter3d/README.md create mode 100644 packages/flame_flutter3d/analysis_options.yaml create mode 100644 packages/flame_flutter3d/example/analysis_options.yaml create mode 100644 packages/flame_flutter3d/example/lib/main.dart create mode 100644 packages/flame_flutter3d/example/pubspec.yaml create mode 100644 packages/flame_flutter3d/example/test/hybrid_test.dart create mode 100644 packages/flame_flutter3d/lib/flame_flutter3d.dart create mode 100644 packages/flame_flutter3d/lib/src/animation/model_animation_component.dart create mode 100644 packages/flame_flutter3d/lib/src/camera/camera_sync_component.dart create mode 100644 packages/flame_flutter3d/lib/src/camera/camera_sync_controller.dart create mode 100644 packages/flame_flutter3d/lib/src/camera/chase_camera.dart create mode 100644 packages/flame_flutter3d/lib/src/camera/projected_viewfinder.dart create mode 100644 packages/flame_flutter3d/lib/src/camera/view_camera.dart create mode 100644 packages/flame_flutter3d/lib/src/debug/hitboxes3d.dart create mode 100644 packages/flame_flutter3d/lib/src/ecs/actor_component.dart create mode 100644 packages/flame_flutter3d/lib/src/ecs/actor_system_component.dart create mode 100644 packages/flame_flutter3d/lib/src/ecs/instanced_actor_component.dart create mode 100644 packages/flame_flutter3d/lib/src/host/bridge_clock.dart create mode 100644 packages/flame_flutter3d/lib/src/host/bridge_priority.dart create mode 100644 packages/flame_flutter3d/lib/src/host/flutter3d_flame_widget.dart create mode 100644 packages/flame_flutter3d/lib/src/host/has_fixed_step.dart create mode 100644 packages/flame_flutter3d/lib/src/host/has_flutter3d.dart create mode 100644 packages/flame_flutter3d/lib/src/host/step_clock.dart create mode 100644 packages/flame_flutter3d/lib/src/host/transparent_flame_game.dart create mode 100644 packages/flame_flutter3d/lib/src/host/updates_at_root.dart create mode 100644 packages/flame_flutter3d/lib/src/input/flame_input_bridge.dart create mode 100644 packages/flame_flutter3d/lib/src/input/taps3d.dart create mode 100644 packages/flame_flutter3d/lib/src/particles/particles3d_component.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/character_body_component.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/collider_registry.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/collision_bridge.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/kinematic_body_component.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/physics_step_component.dart create mode 100644 packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/billboard_atlas.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/bridge_space.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/bridged3d.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/flame_pose.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/instanced_object3d_component.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/node3d_component.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/object3d_component.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/plane.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/projector.dart create mode 100644 packages/flame_flutter3d/lib/src/transform/sprite_billboard_component.dart create mode 100644 packages/flame_flutter3d/lib/src/world/atmosphere_component.dart create mode 100644 packages/flame_flutter3d/lib/src/world/cell_grid_component.dart create mode 100644 packages/flame_flutter3d/lib/src/world/chunk_streamer.dart create mode 100644 packages/flame_flutter3d/lib/src/world/fixture_visuals_component.dart create mode 100644 packages/flame_flutter3d/lib/src/world/grid_mover.dart create mode 100644 packages/flame_flutter3d/lib/src/world/tiled_world.dart create mode 100644 packages/flame_flutter3d/lib/src/world/trail_component.dart create mode 100644 packages/flame_flutter3d/lib/src/world/wrap_space.dart create mode 100644 packages/flame_flutter3d/pubspec.yaml create mode 100644 packages/flame_flutter3d/test/actor_component_test.dart create mode 100644 packages/flame_flutter3d/test/actor_system_component_test.dart create mode 100644 packages/flame_flutter3d/test/atmosphere_component_test.dart create mode 100644 packages/flame_flutter3d/test/bridge_clock_test.dart create mode 100644 packages/flame_flutter3d/test/camera_and_projection_test.dart create mode 100644 packages/flame_flutter3d/test/camera_sync_component_test.dart create mode 100644 packages/flame_flutter3d/test/camera_sync_controller_test.dart create mode 100644 packages/flame_flutter3d/test/cell_grid_component_test.dart create mode 100644 packages/flame_flutter3d/test/character_body_component_test.dart create mode 100644 packages/flame_flutter3d/test/chunk_streamer_test.dart create mode 100644 packages/flame_flutter3d/test/co_op_test.dart create mode 100644 packages/flame_flutter3d/test/collider_registry_test.dart create mode 100644 packages/flame_flutter3d/test/collision_bridge_test.dart create mode 100644 packages/flame_flutter3d/test/curvilinear_space_test.dart create mode 100644 packages/flame_flutter3d/test/flame_input_bridge_test.dart create mode 100644 packages/flame_flutter3d/test/flutter3d_flame_widget_test.dart create mode 100644 packages/flame_flutter3d/test/follows_forge2d_test.dart create mode 100644 packages/flame_flutter3d/test/grid_mover_test.dart create mode 100644 packages/flame_flutter3d/test/has_fixed_step_test.dart create mode 100644 packages/flame_flutter3d/test/has_flutter3d_test.dart create mode 100644 packages/flame_flutter3d/test/input_step_pointer_test.dart create mode 100644 packages/flame_flutter3d/test/instanced_object3d_component_test.dart create mode 100644 packages/flame_flutter3d/test/kinematic_body_component_test.dart create mode 100644 packages/flame_flutter3d/test/model_animation_test.dart create mode 100644 packages/flame_flutter3d/test/node3d_component_test.dart create mode 100644 packages/flame_flutter3d/test/object3d_component_test.dart create mode 100644 packages/flame_flutter3d/test/object3d_transform_test.dart create mode 100644 packages/flame_flutter3d/test/owned_meshes_test.dart create mode 100644 packages/flame_flutter3d/test/particles3d_component_test.dart create mode 100644 packages/flame_flutter3d/test/physics_step_component_test.dart create mode 100644 packages/flame_flutter3d/test/plane_test.dart create mode 100644 packages/flame_flutter3d/test/players_test.dart create mode 100644 packages/flame_flutter3d/test/projected_viewfinder_test.dart create mode 100644 packages/flame_flutter3d/test/refinements_test.dart create mode 100644 packages/flame_flutter3d/test/rigid_body_component_test.dart create mode 100644 packages/flame_flutter3d/test/sprite_billboard_test.dart create mode 100644 packages/flame_flutter3d/test/taps3d_test.dart create mode 100644 packages/flame_flutter3d/test/tiled_world_test.dart create mode 100644 packages/flame_flutter3d/test/trail_component_test.dart create mode 100644 packages/flame_flutter3d/test/transparent_flame_game_test.dart create mode 100644 packages/flame_flutter3d/test/wrap_space_test.dart diff --git a/.github/.cspell/flame_dictionary.txt b/.github/.cspell/flame_dictionary.txt index ada21b99707..18b505f7314 100644 --- a/.github/.cspell/flame_dictionary.txt +++ b/.github/.cspell/flame_dictionary.txt @@ -2,15 +2,19 @@ Artboard # What a project file is called within Rive Aseprite # Animated sprite editor and pixel art tool https://www.aseprite.org/ Audioplayers # A Flutter plugin to play multiple simultaneously audio files https://github.com/bluefireteam/audioplayers +Battlezone # a 1980 Atari tank game in wireframe 3D BGUG # Break Guns Using Gems, a game by BlueFire https://github.com/bluefireteam/bgug Bodymovin # An After Effects extension to export Lottie animations https://aescripts.com/bodymovin/ emberquest # Ember Quest, our platformer tutorial game +firstgid # first global tile id of a Tiled tileset Hermione # A character from the book Harry Potter Kenobi # Eminent Jedi Master, General of the Republic Army, Obi-Wan Kenobi Nakama # An open-source server designed to power modern games and apps https://github.com/Allan-Nava/nakama-flutter +objectgroup # Tiled object layer element Overmind # A character in the game StarCraft padracing # A pad racing game by BlueFire https://github.com/flame-engine/flame/tree/main/examples/games/padracing Prosser # A character from the book The Hitchhiker's Guide to the Galaxy +renderorder # Tiled map attribute riverpod # A state management library for Flutter https://github.com/rrousselGit/riverpod spineboy # Name of a famous character used as an example for Spine https://en.esotericsoftware.com/spine-examples-spineboy spineboys # Plural of spineboy @@ -18,7 +22,11 @@ Spritecow # A handy tool for locating sprites within a spritesheet http://www.sp Supabase # Supabase, one of our sponsors https://supabase.com/ terminui # A terminal UI library for Flutter texturepacker # a packed spritesheet format +tilecount # Tiled tileset attribute +tileheight # Tiled map attribute Tilemap # What tile maps are called within Tiled +tilewidth # Tiled map attribute +tintcolor # Tiled layer attribute typled # A grid map and sprite sheet tool https://github.com/erickzanardo/typled vantablack # brand name for a famous super-black ink known as the darkest ever made Weasley # Ron Weasley, a character from the book Harry Potter diff --git a/.github/.cspell/gamedev_dictionary.txt b/.github/.cspell/gamedev_dictionary.txt index 753253b3587..097952aaec8 100644 --- a/.github/.cspell/gamedev_dictionary.txt +++ b/.github/.cspell/gamedev_dictionary.txt @@ -1,6 +1,8 @@ # general development-adjacent terms and expressions AABB # axis aligned bounding box abelian # Abelian Group, also known as commutative group +affector # something that changes a particle over its life +affectors # plural of affector alignof # alignment of ARGB # alpha red green blue arities # plural of arity @@ -13,10 +15,13 @@ bitfield # data structure consisting of adjacent bits broadphase # common division of collision detection between broad and narrow phases cathetus # the non-hypotenuse sides of a right triangle clusterized # past tense of clusterize +colormap # a texture of flat colors that a model kit's meshes share +flipbook # a sequence of frames shown in turn gles # OpenGL for Embedded Systems glsl # OpenGL Shading Language gltf # OpenGL Transmission Format, a file format for 3D models goldens # test files used as reference for Golden Tests +GTAO # ground truth ambient occlusion highp # high float precission setting on glsl fragment shaders hitbox # the collision box around objects for the purposes of collision detection hitboxes # plural of hitbox @@ -26,6 +31,8 @@ IHDR # PNG header chunk ints # short for integers jank # stutter or inconsistent gap or timing lerp # short for linear interpolation +libm # the C math library +lightmap # a texture holding precomputed lighting LTRBR # left top right bottom radius LTWH # left top width height mediump # medium GLSL float precision @@ -37,7 +44,10 @@ pathfinding # computer algorithm to find the best path through a world or maze perlin # Perlin Noise, a type of noise generating algorithm platformers # plural of platformer, a genre of video game quadtree # a tree-based data structure where each node has exactly 4 children +rasterizer # converts primitives into pixels rasterizing +redrawer # the callback that redraws a frame +Reinhard # Reinhard tone mapping operator respawn # when the player character dies and is brought back after some time and penalties respawned # past tense of respawn retarget # to direct (something) toward a different target @@ -48,6 +58,7 @@ scrollers # plural of scroller, a genre of video game shaderbundle # a file extension used to bundle shaders for GLSL slerp # short for spherical linear interpolation, a method to interpolate quaternions spritesheet # a single image packing multiple sprites, normally in a grid +SSAO # screen-space ambient occlusion ssin # sine of a rotation multiplied by the scale factor subfolders # plural of subfolders sublists # plural of sublist @@ -57,13 +68,16 @@ texel # texture pixel (unit of texture map) texels # plural of texel tileset # image with a collection of tiles. in games, tiles are small square sprites laid out in a grid to form the game map tilesets # plural of tileset +tonemap # map HDR colors to the display range truecolor # truecolor rendering uncapturederror # dumb webgpu javascript name unorm # unsigned normalized integer +unproject # map a screen point back into the scene viewports # plural of viewport WASD # movement keys on a keyboard WBMP # wireless bitmap image format webgpu # gpu standard for web browsers WebP # WebP image format WGSL # WGSL shader language -wgslbundle # WGSL shader bundle format \ No newline at end of file +wgslbundle # WGSL shader bundle format +xorshift # a family of fast pseudorandom number generators diff --git a/.github/.cspell/people_usernames.txt b/.github/.cspell/people_usernames.txt index f17b6d1723c..22433f8a0f7 100644 --- a/.github/.cspell/people_usernames.txt +++ b/.github/.cspell/people_usernames.txt @@ -8,6 +8,7 @@ feroult # github.com/feroult fröber # github.com/Brixto gnarhard # github.com/gnarhard Hoodead # github.com/kornellapu +kazuma # author of the helicopter model on poly.pizza kenney # kenney.nl Klingsbo # github.com/spydon Kornél # github.com/kornellapu diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 40745a7e6af..a01d9cebc3b 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -1,13 +1,27 @@ # actual english words (or common abbreviations) missing from CSpell +autofocused # focused automatically, as a widget given autofocus +behaviour # British spelling of behavior bloodlust +centimetres # British spelling of centimeters collidable collidables +colour # British spelling of color +colours # British spelling of colors +crossfading # blending one animation clip into the next +despawn # remove a spawned entity from the game +despawned # removed from the game after being spawned gamepads grayscale hoverable Hoverables inactives +invertibly # in a way that can be inverted layouting +licence # British spelling of license (the noun) +neighbour # British spelling of neighbor +neighbouring # British spelling of neighboring +neighbours # British spelling of neighbors +normalises # British spelling of normalizes NTSC orientable platformer @@ -24,9 +38,14 @@ renderable rerasterize rescan Roboto +scroller # as in side-scroller, a game that scrolls sideways +starfighter # a small combat spacecraft subclassing tappable thumbstick trackpad underutilize +unflipped # not flipped +unshields # takes a shield off +unticked # not advanced by a tick untinted diff --git a/doc/bridge_packages/bridge_packages.md b/doc/bridge_packages/bridge_packages.md index e45dad0d8d8..e1110b93eba 100644 --- a/doc/bridge_packages/bridge_packages.md +++ b/doc/bridge_packages/bridge_packages.md @@ -31,6 +31,12 @@ with their games. Create texture atlases for games (bridge package for [FireAtlas]). ::: +:::{package} flame_flutter3d + +A 3D layer under a Flame game, with HDR post-processing and one shared game loop (bridge package for +[flutter3d]). +::: + :::{package} flame_forge2d A Box2D physics engine (bridge package for [Forge2D]). @@ -100,6 +106,7 @@ Load Typled sprite atlases with edge-repeated padding (bridge package for [Typle [AudioPlayers]: https://github.com/bluefireteam/audioplayers [Bloc]: https://github.com/felangel/bloc [FireAtlas]: https://github.com/flame-engine/fire-atlas +[flutter3d]: https://pub.dev/packages/flutter3d [Forge2D]: https://github.com/flame-engine/forge2d [gamepads]: https://github.com/flame-engine/gamepads [Lottie]: https://pub.dev/packages/lottie @@ -119,6 +126,7 @@ flame_audio flame_behaviors flame_bloc flame_fire_atlas +flame_flutter3d flame_forge2d flame_gamepads flame_isolate diff --git a/doc/bridge_packages/flame_flutter3d/flame_flutter3d.md b/doc/bridge_packages/flame_flutter3d/flame_flutter3d.md new file mode 100644 index 00000000000..921e045e32b --- /dev/null +++ b/doc/bridge_packages/flame_flutter3d/flame_flutter3d.md @@ -0,0 +1,150 @@ +# flame_flutter3d + +**flame_flutter3d** puts a [flutter3d] scene under a Flame game. Flame keeps running the game and +drawing its own layer: components, effects, collisions, input, overlays. flutter3d draws the 3D +layer beneath it, with its own renderer and post-processing chain. Neither engine reimplements the +other, and both run on Flame's clock. + +flutter3d draws through Flutter GPU (Impeller) on desktop and mobile, through WebGL2 or WebGPU in a +browser, and through a software rasterizer when there is no GPU at all, which is what the package's +tests use. + + +## One game, two layers + +Mix `HasFlutter3d` into the game and show it with `Flutter3dFlameWidget` instead of `GameWidget`: + +```dart +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart'; + +class MyGame extends FlameGame with HasFlutter3d { + @override + void onOpen3d() { + scene.add(LightNode(name: 'sun')); + world.add( + Object3dComponent( + node: MeshNode(crateMesh, crateMaterial), + scene: scene, + plane: BridgePlane.ground(), + ), + ); + } +} + +// In the widget tree: +Flutter3dFlameWidget(game: MyGame()) +``` + +`Flutter3dFlameWidget` stacks Flame's `GameWidget` over a flutter3d `SceneSurface`. Flame is on top +because it needs raw input, and `HasFlutter3d` gives the game a transparent background so the 3D +layer shows through. The game owns its `scene`, `camera3d`, `device` and `renderer`, and builds the +world in `onOpen3d`, which runs once after the game has loaded. + + +## One loop + +There is no second ticker. A `BridgeClock` component sits in the game and draws the 3D frame after +Flame's components have updated, so a `MoveEffect` that moved a component this frame is drawn in 3D +this frame. `BridgePriority` names the order the bridge's own components run in: input, actors, +physics, your components, Flame's camera, the camera sync, sound, and the clock last. + +`HasFixedStep` runs a game's own logic in fixed steps, so the same second of play gives the same +result at 30 and at 120 frames per second. The physics and the actor system step inside those +steps, and bodies are drawn between two steps rather than jumping from one to the next. + + +## Where a Flame point is in 3D + +`BridgePlane` is the one place a Flame `Vector2` and a flutter3d `Vector3` mean the same point. +`BridgePlane.ground()` lays Flame's world flat for a top-down game, where Flame's `y` becomes the +scene's `z`; `BridgePlane.backdrop()` stands it up for a side-scroller. `CurvilinearSpace` bends it +along a road, and `WrapSpace` makes its edges meet. + +`Object3dComponent` keeps a Flame `PositionComponent` and a scene node in step, in whichever +direction a `SyncDirection` names. Position, angle, scale, visibility, opacity and a tint cross, and +Flame's effects, hitboxes and children work on it as on any other component. A component that did +not move writes nothing, so a still prop does not trigger a shadow redraw. + +Other components cover what a game usually needs next: + +- `InstancedObject3dComponent` draws many components of one shape as one draw call. +- `Node3dComponent` is a component in full 3D, moved by `Move3dEffect`, `Rotate3dEffect` and + `Scale3dEffect`. +- `SpriteBillboardComponent` stands a Flame `Sprite` or `SpriteAnimation` in the scene facing the + camera. +- `RigidBodyComponent`, `ActorComponent` and `CharacterBodyComponent` carry a flutter3d body or + actor across, and `CollisionBridge` reports its contacts through Flame's own + `CollisionCallbacks`. +- `Object3dComponent.follows` takes any Flame position provider, so a `flame_forge2d` body can be + drawn in 3D. +- `TiledWorld3d` stands a Tiled map, the one `flame_tiled` reads, up in 3D. +- `ChaseCamera` and `CameraSyncController` move the 3D camera; given an `eyeOffset`, Flame's own + camera (`follow`, `setBounds`, zoom) drives a perspective one. +- `ProjectedViewfinder` and `Tap3dCallbacks` make Flame's taps land on what the player sees under a + perspective camera. + + +## Post-processing + +The 3D layer renders in HDR and goes through a post-processing chain before it is composited under +Flame. `HasFlutter3d.renderSettings` is read before every frame, so a game turns effects on and off +by returning different settings: + +```dart +class MyGame extends FlameGame with HasFlutter3d { + bool cinematic = false; + + @override + RenderSettings renderSettings() => RenderSettings( + fog: fog3d, + tonemapCurve: TonemapCurve.agx, + bloom: const BloomSettings(intensity: 0.08), + ambientOcclusion: const AmbientOcclusionSettings( + enabled: true, + method: AmbientOcclusionMethod.gtao, + ), + antiAlias: const AntiAliasSettings( + enabled: true, + temporal: TemporalSettings(enabled: true), + ), + depthOfField: DepthOfFieldSettings(enabled: cinematic), + ); +} +``` + +What is available: tone mapping (Neutral, ACES, AgX, Reinhard), exposure and auto exposure, local +exposure, bloom, SSAO and GTAO, screen-space reflections, contact shadows, light shafts, volumetric +fog, depth of field, motion blur, temporal anti-aliasing, color grading through a LUT, and spatial +upscaling. Each setting is documented in the [flutter3d API reference]. + + +## Running on the web + +A web build draws through WebGL2. To try WebGPU first and fall back to WebGL2 where the browser has +no adapter, build with: + +```shell +flutter build web --dart-define=FLUTTER3D_WEBGPU=true +``` + +The flag is off by default because it adds the WebGPU backend to the bundle, which costs about +368 KiB of JavaScript. + +On desktop and mobile, Flutter GPU has to be enabled for the platform, or the 3D layer draws +nothing: `FLTEnableFlutterGPU` and `FLTEnableImpeller` in `Info.plist` on macOS and iOS, and +`io.flutter.embedding.android.EnableFlutterGPU` in `AndroidManifest.xml` on Android. + + +## Examples + +- [The package example](https://github.com/flame-engine/flame/tree/main/packages/flame_flutter3d/example): + a Flame HUD over a 3D yard, a cube Flame steers, and a crate whose landing Flame hears. +- The `flame_flutter3d` stories in the [Flame examples](https://examples.flame-engine.org): + post-processing, the shared loop, and a Tiled map in 3D. +- [River Sortie](https://github.com/flame-engine/flame/tree/main/examples/games/river_sortie): a + small River Raid-style game built on the bridge. + +[flutter3d]: https://pub.dev/packages/flutter3d +[flutter3d API reference]: https://flutter3d.pleion.dev/docs diff --git a/packages/flame_flutter3d/CHANGELOG.md b/packages/flame_flutter3d/CHANGELOG.md new file mode 100644 index 00000000000..a9779598d36 --- /dev/null +++ b/packages/flame_flutter3d/CHANGELOG.md @@ -0,0 +1,718 @@ +## 0.8.4 + +**An actor system with several foci.** `ActorSystemComponent(foci:)` steps +the system towards every player of a co-op game, so each actor goes for the +one it can reach first; it could only name one focus, and every monster went +for player one. Its `flutter3d_sim` dependency asks for `^0.8.1`. + +**A step's reports survive the game's own logic.** In a `HasFixedStep` game +the actor system's step is opened at the start of each step, through the new +`HasFixedStep.beforeEachStep`, rather than just before the actors: a monster +killed by a shot fired in the game's `fixedUpdate` was wiped from `died` +before anything read it. + +**A body the game moves is drawn between its steps.** `StepClock` and +`StepFollower` are what draws between steps asks of what steps; the game is +one (`HasFixedStep`), as are `ActorSystemComponent` and +`PhysicsStepComponent`. `ActorComponent.stepper` takes any of them and +`CharacterBodyComponent` takes one: a body the game's own simulation moved +kept its place after the move and was drawn with no smoothing. + +**An actor and its component live and die together.** An `ActorComponent` +whose actor the simulation removed takes itself out; one handed +`removesFrom` takes the actor out of the system when it goes. The actor used +to go on thinking unseen, or the node to stand where the actor had been. + +**A horde in one draw.** `InstancedActorComponent` draws a simulated actor +as a slot of a shared `InstancedMeshNode`, between its steps, and +`InstancedPoseComponent` does the same for anything the simulation keeps +that is not an actor, a shot say. Two hundred monsters were two hundred +nodes. + +**Keys from the keyboard, not the focus.** `listenToKeyboard()` on a +`FlameInputBridge`, `PlayerInputs` and the new `PlayerSeats` reads keys +from `HardwareKeyboard`: through the game's focus, a key held while an +overlay took it was held for good. `PlayerSeats` keeps every way of holding +the game and lets players claim one by pressing, in the order they join. + +**A level is a scene.** `HasFlutter3d.replaceScene3d` moves the game to the +next level's scene with the camera, and `Flutter3dFlameWidget` draws the +game's scene as it is now. `FixtureVisualsComponent` syncs a level's +fixtures once a frame and lets them go with the level. `ViewCamera` eases a +camera towards wherever a function says, for a view no single component +decides — a whole party's. + +**The steps no longer walk the whole game every frame.** `HasFixedStep` +keeps its list of `FixedStepUpdate` components and walks the tree again only +when one comes, goes or changes priority: a game with a horde walked hundreds +of components sixty times a second to find the few that step. + +**A pad is read against the frame it is read in.** `HasFixedStep.frameSeconds` +is this frame's time, set before `beforeSteps`; the pad feed used the frame +before's, nought on the first frame and the stall's after one. + +**The camera sync follows Flame's camera wherever it is added — again.** +0.8.3 ordered a `CameraSyncComponent` flowing Flame to the scene after +Flame's `CameraComponent` by its priority, and a priority orders siblings +only: added to the world, where a game adds its components, it ran inside +the world, before the camera, and the 3D camera trailed `camera.follow()` +by a frame once more. `UpdatesAtRoot` is what fixes it and the two others +with the same assumption: a component with it, mounted anywhere but the +game's root, does its frame's work from a driver at the root at its own +priority. The input step's end has it, so a press closed from the world is +still seen by a button in the viewport, and so has +`flame_flutter3d_audio`'s `AudioSceneComponent`. + +**An upright billboard turns about its own plane's normal**, and writes its +rotation only when it changed. It turned about world Y whatever the plane, so +on a backdrop the card swung about an axis lying in it, and a still card under +a still camera marked its node moved every frame. + +## 0.8.3 + +**A host that goes lets go of the game.** `Flutter3dFlameWidget` cleared +the game's `redrawer3d` by comparing it with a fresh tear-off of its own +method, which is never identical, so a game that outlived its widget kept +the disposed host and everything it held. + +**Only a host showing the game ticks it.** A host added its clock on its +first build; one still opening its device, or one that failed to start, +ticked a game another host was showing, and `onTick` ran twice an update. + +**New overlay builders reach the screen without a new `GameWidget`.** A map +written inline in a parent's `build` is new on every rebuild, and each one +replaced the `GameWidget`, which updated the game again from its layout. +Builders under the same names are now read through the current config. + +**`BillboardAtlas` keeps what it uploads straight.** A material is kept per +image and sampling, so a smooth caller no longer gets a sharp one's; an +upload that finishes after `dispose` makes no texture; and textures go back +after the frames in flight, as the cards do. + +**A character or a lift leaves the world with its component.** +`CharacterBodyComponent` and `KinematicBodyComponent` take `removeFrom`, as +`RigidBodyComponent` does; a despawned one no longer stays solid and unseen. + +**A game shown again draws the world it kept.** Flame keeps a game's +components when its widget goes, and the same game can be shown again on a +tab that comes back. `Flutter3dFlameWidget` closed the device it had opened +under a world still built on it, and the game came back with meshes on a +closed device and no particles. The device now goes with the game: +`HasFlutter3d.close3d()` lets it go, `dispose()` calls it, and +`onClose3d` is where a game that will be shown again takes down what it +built. + +**Another game handed in gets a world of its own.** A rebuild with a +different `game` drew the new game over the old one's scene; the widget +now starts afresh for it. A scene or renderer that threw on the way up no +longer leaves its device open, and a world that throws while it is built +says why where the game would be. A camera a rebuild replaced is taken out +of the scene, and new overlays or focus reach Flame's widget. + +**A paused game can be drawn.** `HasFlutter3d.redraw3d()` draws the 3D +layer once without an update, for a pause menu that changes the sky. + +**A flipped component turns the way Flame draws it, nested or not.** +Flame's `absoluteAngle` is reflected for a flipped component, and written +beside the signed scale the mirror was applied twice: a flipped ship under +anything turned the opposite way. The chain is now folded the way Flame's +matrices compose it, on the way out and on the way back, for both bridged +components. A plain `Component` between a component and a positioned +ancestor no longer hides the ancestor. + +**A press is seen by one step.** In a `HasFixedStep` game the input step +is closed after each fixed step rather than each frame: a frame of three +steps showed a jump's press to all three. `HasFixedStep.afterEachStep` +is where it is closed. `PhysicsStepComponent` and `ActorSystemComponent` +in such a game step in the game's steps, in tree order, and draw by its +`alpha`, rather than counting steps of their own. + +**Contacts end when a partner goes.** A component removed mid-contact +ends the contact on the other side, as Flame's own hitboxes do; the other +side went on counting it among its `activeCollisions`. `ColliderRegistry` +keeps a component moved to another parent, and finds a removed one again +when it is added back. + +**Bodies can be moved and let go of.** `RigidBodyComponent.teleport` puts +a body somewhere still and awake and draws it there at once; written into +the collider, a respawn slid across the level. Handed `removeFrom`, a +removed crate takes its body out of the world instead of leaving it solid +and unseen. A component added back draws from where its body is. + +**Touch that moves keeps holding.** `PointerTrack` holds its action +through a drag of the same finger, which Flutter reports as a cancelled +tap; firing while dragging to aim stopped the moment the aim moved. It and +`SwipeInput` pass drags on to what is under them, a stick say. A touch +stick at rest writes its zero once, not every frame over a pad's stick, +and takes a `deadZone`. A window that loses focus lets go of every key. + +**A wrapped world tells a contact once.** Two craft by the same edge met +really and through their ghosts, two across the seam through each one's +ghost, and every hit was reported twice. A ghost now tells its owner of a +meeting only when nothing else will. A ghost has its owner's hitbox's +collision type, solidity and shape, polygons included, and its meshes take +the owner's tint and opacity every frame. + +**What a component makes, it gives back.** A `CellGridComponent` removed +gives back the mesh it was standing in. A `Particles3dComponent` added back +is drawn again as before. A `TrailComponent` widens its line against the +new size after a resize, breaks rather than drawing across the world when +its component jumps further than `breakAt`, and can `reset()`. + +**Flame's events land through any viewport, and the sky is the horizon.** +`ProjectedViewfinder` brings Flame's viewport points into the canvas the +projector works in, so a `FixedResolutionViewport` no longer puts every tap +somewhere else. A point on the sky comes back as the plane point out at the +horizon, through `BridgeProjector.onPlaneOrHorizon`, rather than NaN: Flame's +`World` takes every point, and a drag that strayed above the horizon moved +its component to NaN for good. + +**What reads Flame's camera runs after it.** Flame gives its +`CameraComponent` the highest 32-bit priority, and the clock, the sound and +the input's end sat below it, so a 3D camera synced from a viewfinder that +`camera.follow()` moves trailed it by a frame. `BridgePriority` now has +`flameCamera` and `afterFlameCamera`, the three run past it, and a +`CameraSyncComponent` flowing Flame to the scene runs after Flame's camera +by default. `CameraSyncController.takeRest` takes a camera's rotation as its +rest after a `lookAt`, and the lens is made only when the zoom moves. + +**An instance is a bridged component too.** `Bridged3d` is what taps and +hitbox outlines ask of a component, and `InstancedObject3dComponent` is +one: an invader drawn as one instance of fifty-five can be tapped and have +its hitbox drawn, and takes a `space` as a node does. Hitbox outlines bend +with a component's space. + +**Taps are nearest where they meet, and have an end.** `Taps3dComponent` +ranks what is under a tap by where the ray enters each box, not by each +box's middle, so a crate standing on a wide field hears the tap rather +than the field. `Tap3dCallbacks` hears the finger lift (`onTapUp3d`), the +tap given up on (`onTapCancel3d`) and a finger held still (`onLongTap3d`). +`BridgeProjector.rayThrough` is the ray through a screen point. + +**A tint moves as a colour effect would.** `TintEffect` moves a bridged +component's tint on any `EffectController`, since Flame's own `ColorEffect` +wants a paint a 3D component does not have. + +**A clip can be played again.** `ModelAnimationComponent.play` takes +`restart`: asking for the clip already playing did nothing, so a jump +played once a game. + +**The day's fog reaches the frame.** `AtmosphereComponent` writes its fog +into `HasFlutter3d.fog3d`, which the game's default settings draw with; a +game had to know to read it across by hand. + +**An actor turns between its steps.** `ActorComponent` draws its facing +the same fraction of the way between two steps as its place, the short way +round; the place glided and the facing clicked. Added back, an actor or a +body draws from where it is, not from where it was when it went. + +**Nothing behind an orthographic camera is on the screen.** +`BridgeProjector.toScreen` returns null for a point behind an orthographic +camera, as it did for a perspective one. + +**A touch stick is read before the steps.** In a `HasFixedStep` game the +steps run before any component updates, and a stick read in its own +component reached them two frames after the finger moved. +`HasFixedStep.beforeSteps` is where what the steps read is gathered. + +**`onCollision` once a frame, and a ray through the world.** A +`CollisionBridge` handed its `stepper` relays `onCollision` once a frame +for each partner, as Flame's own detection does, rather than once a step; +`PhysicsStepComponent.frame` counts the frames. `ColliderRegistry.raycast` +fires a ray across the plane through the collision world, exact per shape, +with layers and triggers, and says which component it met and where. + +**A wrapped world carries bodies across, and its ghosts can be tapped.** +A child of a `WrapSpace` placed from the scene side, a body the physics +steps, is carried across the seam in the scene as well, still moving, +through `Object3dComponent.shiftScene`, which a rigid body, an actor and a +character body override to move their bodies; its Flame position was +wrapped and read straight back from the body on the far side. A tap on a +craft's ghost reaches the craft: `Tap3dCallbacks.drawnBoxes3d` includes +`WrapSpace.ghostBoundsOf`. + +**Flame's camera drives a perspective one.** Given an `eyeOffset`, a +`CameraSyncController` flowing Flame to the scene looks at the +viewfinder's point from that offset, nearer as the viewfinder zooms and +round as it turns: Flame's `follow` with its `maxSpeed`, `setBounds`, +`moveTo` and effects on the viewfinder all move the 3D camera, as they +would a flat Flame game. Before, a perspective camera was put on the plane +at the viewfinder's point. `ProjectedViewfinder` works out Flame's +`visibleWorldRect` from what the 3D camera shows, so `canSee` and bounds +that mind the viewport are right under a perspective lens. + +**A split screen.** `HasFlutter3d.viewport3d` is the part of the canvas the +game's camera draws into, and `moreViews3d` are further views drawn into +the same frame: the second player's half, a mirror. `BridgeProjector` takes +a `viewport`, so taps and labels work in each half. Needs +`flutter3d_app` 0.8.1, whose `SceneSurface` draws more than one view. + +**Players at one machine, and a pad on Flame's clock.** `PlayerInputs` +hands each key to every player's `FlameInputBridge`, so two on one +keyboard each move their own; forwarded to one bridge, player two's arrows +moved player one. `FlameInputBridge.followPad` ticks a `PadInput` in each +frame, before the steps of a `HasFixedStep` game: nothing ticked one in a +Flame game. A second player's controller is a `PadInput` over +`Gamepad(index: 1)`, which `pad_input` 0.4.3 reads. + +**An isometric board.** Under an orthographic lens `eyeOffset` is the angle +of view: the camera looks along it, and the zoom stays the lens's height. + +**A lift Flame moves.** `KinematicBodyComponent` moves a kinematic collider +to where Flame puts it, with Flame's own effects, through `Collider.moveTo`, +so a character standing on it is carried; a step in which it did not move +clears the motion, so the passenger is carried once for each move and not +again on every step after. Written into the collider by hand, the lift moved +and its passenger stayed. `BridgePriority.kinematic` runs it before the +actors and the physics. + +**A grid is a world to move and collide in.** `CellGridComponent` can draw +its cells `instanced`, each a slot in one batch, so a cell taken or put back +is one slot rather than the whole grid rebuilt: a field dug a cell at a time +rebuilt thousands of blocks for each swing of the spade. With `hitboxes`, +each cell has a solid, passive Flame hitbox of its own, taken with it, so +Flame's own collision and raycast meet the walls. `setCell` grows a grid as +well as wears it, and `cellAt` and `centreOf` turn points into cells and +back. `GridMover` is a behaviour that walks its parent from the middle of +one cell to the next: a turn asked for is kept until a junction opens to +it, a turn back is taken at once, a wall stops it, and with `wraps` a way +off one edge comes in at the other. + +**A level drawn in Tiled.** `TiledWorld3d` stands a `TiledMap` up in 3D, +the map `flame_tiled`'s `TiledComponent` reads or `TileMapParser` parses: +each tile layer becomes a `CellGridComponent` with a block where a tile is, +set up by its custom properties in Tiled (`solid` for Flame hitboxes, +`depth`, `elevation`, `merged`) and painted its tint colour, and each object +is handed to the game with its middle in metres. A maze, a castle's rooms +or a mine's shafts are drawn in the editor instead of typed as masks. + +**Flame's own physics, drawn in 3D, and a game of any world.** +`Object3dComponent.follows` takes any of Flame's position providers, and an +angle provider's angle too: a `flame_forge2d` `BodyComponent` is both, so a +pinball's ball and flippers moved by forge2d's solver are drawn in 3D. A +forge2d body is not a `PositionComponent`, and nothing of the bridge could +be hung under it. `HasFlutter3d` and `HasFixedStep` are generic over the +game's world: on `FlameGame` alone they could not be mixed into a +`Forge2DGame`, or any game whose world has a type of its own. + +**Flame's sprites stand in the scene.** `SpriteBillboardComponent` draws a +Flame `Sprite`, or a `SpriteAnimation` played by Flame's own ticker, on a +card that turns to face the camera, upright about the plane's normal or +squarely, its foot on the plane: a car on a road, a tree beside it, an +explosion. The image goes to the device once, each frame is a card of its +own corners, and it is drawn unlit, cut out where the sprite is clear and +sampled nearest, as pixel art wants. + +**A Flame component in full 3D.** `Node3dComponent` is a Flame component +with a place, a quaternion turn and a scale on each axis in the scene, no +plane under it: a starfighter, a tank on an open plain. Under another it +hangs from its parent's node, so a turret turns with its tank, and a camera +added to a ship's node is a cockpit. `Move3dEffect`, `Rotate3dEffect` and +`Scale3dEffect` move it on any of Flame's `EffectController`s, `TintEffect` +and `OpacityEffect` colour and fade it, and `Tap3dCallbacks` hears a tap on +it: taps now ask for `Drawn3d`, what is drawn and where, which every bridged +component is too. + +**Billboards share their pictures, and a one-shot goes.** `BillboardAtlas` +holds one texture and one material for each image and one card for each +part of it a frame shows, so a bank of reeds drawn from one sheet uploads +it once and draws as one; a billboard handed none makes its own. +`SpriteBillboardComponent.removeOnFinish` takes a one-shot animation away +when it has played, as `SpriteAnimationComponent`'s does. River Sortie +stands reeds and bushes along its banks and a flash in each blast. + +**A billboard can say something.** `BillboardAtlas.spriteOfText` writes a +string with Flame's `TextPaint` into a sprite of its own, in whatever font, +weight, colour and shadows the paint has, for a sign by the road or a name +over a craft. `SpriteBillboardComponent.smooth` samples it linearly, as +lettering wants, and a billboard's `sprite` can be changed while it stands: +a new picture is uploaded first and the card keeps the old one until it is +there. River Sortie's fuel depots say FUEL, as they always have. + +**Moved is not gone.** Flame moves a component to a new parent by removing +and mounting it, and a component's `owns` meshes were let go of in the +removal while it went on drawing them. + +**Flame's effects reach the scene in the frame they happen.** Flowing Flame +to the scene, `Object3dComponent` writes the scene again in `updateTree`, +after its children, and an effect is a child: written only in `update`, +before them, every `MoveEffect` and `RotateEffect` drew a frame late. It +still writes in `update` as well, so code that drives a component by +calling `update` itself, as the showcase's transform page does, keeps +working. Flowing the other way it reads the scene in `update`, so its +children see this frame's body. + +**A nested component lands where Flame draws it.** The transform written +into the scene is the absolute one, so a component under another, a frog +on a log, is placed at the log plus the frog. It wrote its local position +as a world one. Read back from the scene, a nested component's position is +brought into its parent's space. + +**A component let go stops being drawn at once.** `removeFromParent` hides +its node straight away; Flame takes the component out on its next +lifecycle pass, and until then the node was drawn a frame too long. + +**The rest of Flame's transform crosses.** `elevation` lifts a component off +its plane along the normal, so one plane serves what floats and what flies +over it; `scenePosition` says where it is in the scene. Flame's `scale` +scales the node. Flame's visibility (`HasVisibility.isVisible`) hides and +shows it, written only when it changes, so a node blinked by hand still +blinks. And `visual`, a node under the bridged one made on first use, is +the game's to turn, bank or tilt: the bridge writes the bridged node's +rotation every frame and never touches `visual`'s. River Sortie dropped its +second plane, its hand-made pivot nodes and its node-level show and hide; +Meteor Yard its pivot map. + +**`Flutter3dFlameWidget.onRendererReady`** hands a game the `Renderer` the +3D layer draws with, once it exists, for what only the renderer can do: +letting go of a streamed mesh after the frames in flight +(`Renderer.releaseMeshAfterFrame`), adding a contributor. + +**`Object3dComponent` takes a `size` and an `anchor`.** A bridged component +that collides needs both: a `RectangleHitbox()` fills its parent's size, and +the anchor decides whether the point written into the scene is the centre +or the top-left corner. They were Flame's and set in every subclass's +constructor body, five times in River Sortie alone; they pass through the +constructor now. + +**A phone's stick and button go through the input bridge.** +`FlameInputBridge.followJoystick(stick)` returns a component that writes a +Flame `JoystickComponent`'s deflection into the move axis every frame, +screen-up as forward, the way a gamepad's stick goes in; `bindButton(button, +action)` holds an action while an on-screen button is down. River Sortie +polled its stick in `update` and wired the button's three callbacks itself. + +**`ChaseCamera` follows a bridged component in perspective.** From an +offset behind it, looking at a point ahead, following part way across if +asked, stiff or springy. It eases through `flutter3d_sim`'s `CameraRig`, +so `chase.rig.shake(0.5)` shakes it and a `CollisionWorld` with walls in it +keeps it out of them. `ChaseCameraComponent` runs one as a component. +River Sortie's hand-written camera went, and its camera shakes when the jet +goes down. + +**`BridgeProjector` goes between the 3D camera and Flame's screen.** +`toScreen` says where a point of the scene is drawn, for a label or a +"+30" in Flame's viewport over a craft; `onPlane` says which point of a +plane is under a touch. + +**`ChunkStreamer` builds an endless world piece by piece.** Given how a +piece is built and let go, `cover(from, to)` builds what came into view, +in order, and drops what left it; `clear` drops everything for a restart. +River Sortie's stretches of river are one. + +**`InstancedObject3dComponent` draws many small things as one.** A Flame +component that takes a slot in a shared `InstancedMeshNode` while mounted, +writes its transform into it the way `Object3dComponent` writes a node's, +and gives it back when removed, at once. River Sortie's shots are one draw +however many are in the air. + +**`Particles3dComponent` runs a `flutter3d_particles` system on Flame's +clock**, bursting from a Flame point with `burstAt`, and draws it through a +`MeshParticleContributor` once `drawWith` has the renderer, additively by +default or with `blend: MeshParticleContributor.darkening` for smoke. River +Sortie's fire, sparks and spray went into one pool and its smoke into +another, and `BurstComponent`, a scene node per shard, is gone. The package +now depends on `flutter3d_particles` 0.8.1, which is plain Dart. + +**A platformer's runner, reached from Flame.** `CharacterBodyComponent` +carries a bare `CharacterController` across the bridge and steps it with +`drive`, in the game's fixed steps when it has them: a +`flutter3d_game_platformer` runner, which already runs, jumps twice and +climbs ladders and ropes, moved by its own rules and drawn between steps. + +**A day, a worn shield and a missile's trail.** `AtmosphereComponent` runs +an `AtmosphereCycle` on Flame's clock and puts the air on the game's scene, +its sun and its sky, with the fog for its `renderSettings`. +`CellGridComponent` is a `CellGrid` drawn as blocks, whose `hitAt` wears +away the cells round a point and says whether the shot met one, drawing +what is left and letting the old mesh go. `TrailComponent` lays a +`LineStripNode` behind the bridged component it is added to. + +**A road that bends under Flame's straight world.** `BridgeSpace` is where +a Flame point is placed and turned in the scene; `BridgePlane` is the flat +one, and `CurvilinearSpace` lays Flame's world along an `OpenPath`: `x` is +metres right of the road's middle, `-y` metres along it, and an angle turns +from the road's heading. `Object3dComponent(space:)` writes through it, so +an Enduro car keeps Flame hitboxes that mean side by side on the road +however the road winds. + +**A world whose edges meet.** `WrapSpace` wraps its children's positions +round a rectangle, draws a ghost of each child within `margin` of an edge +on the other side (three in a corner) so a ship half over an edge is seen +on both, and gives the child ghost hitboxes one world across, so Flame's +own collision detection finds a contact across the seam and reports it to +the child itself. `shortestWay` is the direction across an edge when that +is shorter. + +**An orthographic camera agrees with Flame to the pixel, and rolls.** +`CameraSyncController` takes a `viewportHeight`: with it, Flame's zoom is +pixels per world unit, the viewport's height over the camera's, rather +than the reciprocal convention that moved the right way and matched +nothing on screen. `syncAngle` keeps Flame's viewfinder angle and the +camera's turn about the plane's normal the same. + +**`Flutter3dFlameWidget` passes Flame's overlays and focus on**: +`overlayBuilderMap`, `initialActiveOverlays`, `focusNode` and `autofocus` +reach the `GameWidget`, so a pause menu over the 3D layer is Flame's own +overlay rather than a second `Stack`. + +**A model's animations play on Flame's clock.** `ModelAnimationComponent` +advances a loaded model's `AnimationPlayer` in its own update, so it stops +when the game is paused, and changes clip by name with a crossfade; asking +for the clip already playing does nothing, so a game can ask every frame. +`MeshFlipbookComponent` shows a handful of meshes in turn, an invader's two +poses. + +**Flame's own events land where the player sees things.** +`ProjectedViewfinder` maps the screen to the game's plane through the 3D +camera: a component's `TapCallbacks`, Flame's hit test and +`camera.globalToLocal` in a game's code find the plane point under the +finger, where the viewfinder's affine transform put it metres away under a +perspective camera. The sky meets no plane and hits nothing. It changes +events and conversions; Flame still draws its world flat. + +**A component lets go of the meshes it made.** `Object3dComponent(owns:)` +names meshes built for one component, a bridge's span, and gives them back +when the component is removed: through the renderer after the frames in +flight in a `HasFlutter3d` game, at once when there is no renderer. River +Sortie's bridges own their span and shield. + +**An actor hears its contacts, and an instance has a colour.** +`CollisionBridge` relays to any component with Flame's collision callbacks, +an `ActorComponent` among them, rather than only a `RigidBodyComponent`; +a component that is not bridged is given a plane. `InstancedObject3dComponent` +has a `tint` and is an `OpacityProvider`, written into its slot's colour: +a hit flash on one invader of many. + +**A game's own logic can run in fixed steps.** `HasFixedStep` on a +`FlameGame` spends each frame's time in steps of one size and calls +`fixedUpdate` on the game and on every `FixedStepUpdate` component in each +step, before Flame's once-a-frame `update`. The physics and the actors +already stepped so; a jet flown by `speed * dt` did not, and the same +second of play flew a different distance at 30 and at 120 frames a second. +`stepEnd` leaves the input step open after a frame with no step in it, so +a press is not closed before anything has read it. River Sortie's run, its +targets, bridges and shots are in fixed steps now. + +**A body at rest costs nothing either.** `RigidBodyComponent` and +`ActorComponent` wrote their body's place onto the node every frame, and a +sleeping crate redrew every shadow as a still prop had. They write through +`placeNode` and `turnNodeTo`, which leave a node alone where it already is. + +**The input step closes itself, and the pointer and swipes are input.** +`FlameInputBridge.stepEnd()` is a component that calls `endStep` once +everything has read the frame's input, which each game did by hand as the +last line of its `update`. `pointer(press:)` follows the pointer as an +`aim` and holds an action while a tap is down; `swipes(...)` turns a swipe +into one press of its direction's action. + +**A still prop costs nothing, and no shadow is redrawn for it.** A node's +setters mark it changed whatever they are given, and the engine keeps its +shadow cascades and its bounds tree only while nothing changed. Every +bridged component rewrote its place every frame, twice, so one still +tanker had every shadow redrawn every frame. `Object3dComponent` and +`InstancedObject3dComponent` now write only when Flame's transform moved, +without making a vector or a quaternion to do it; `BridgePlane.to3dInto` +and `rotationInto` are the allocation-free forms. `rewriteScene` forces +the next write for a caller that moved the node itself. + +**`BridgePriority` names the order a bridged frame runs in**: input, the +actors, the physics, the game's own components at Flame's default, the +camera, the sound, the clock. The bridge's components take those numbers +by default; each game had picked its own (the arcade -120 and -110). + +**`ColliderRegistry` is the collider-to-component map every game with +contacts kept by hand.** An entry leaves when its component leaves the +game, and `bridge` makes a `CollisionBridge` that looks the other side up +there. The arcade's own map went. + +**`Flutter3dFlameWidget` follows a rebuild.** A new camera or clear colour +handed in from above is drawn with, and a new camera is added to the +scene; both went into the view once and a rebuild changed nothing on +screen. In a debug build it says so when the game paints an opaque +background over the 3D layer, rather than leaving a screen of one colour. + +**Flame's opacity and a tint reach the 3D layer.** `Object3dComponent` +is an `OpacityProvider`, so Flame's `OpacityEffect` fades every mesh under +its node, and its `tint` colours them, through `MeshNode.tint`; a model +dressed onto the node later takes them too. River Sortie's wrecks go down +charred and a fallen bridge fades under the water rather than blinking out. + +**A tap lands on what the player sees.** Flame's `TapCallbacks` asks a +component whether a point is inside it on the plane the game plays on, +which under a perspective 3D camera is not where the component is drawn. +`Tap3dCallbacks` on a bridged component hears `onTap3d` when a tap falls on +the screen rectangle its node covers, through the game's projector, and a +`Taps3dComponent` in a `HasFlutter3d` game hands each tap to the nearest +such component under it, or lets it through to the rest of Flame. +`BridgeProjector.boundsOf` gives the screen rectangle of a box. + +**Hitboxes can be seen where they are.** Flame's `debugMode` draws a hitbox +flat on its own canvas, nowhere near a craft drawn in perspective. +`HasFlutter3d.debugHitboxes3d` draws every bridged hitbox in the scene, +round its craft at its height, green, and red while it collides; +`addHitboxes3d` is the same for any `DebugDraw`. River Sortie shows them +with `--dart-define=RIVER_HITBOXES=true`. + +**Physics and actors step in fixed steps.** `PhysicsStepComponent` and +`ActorSystemComponent` passed Flame's `dt` straight to the solver, so the +same jump reached a different height on a faster screen and a stalled frame +let a fast body step through a wall. Both now spend the frame's time in +steps of one size through `flutter3d_sim`'s `FixedStep` (a sixtieth of a +second unless given `step:`), at most five of them after a stall, and +dispatch contacts after each step. A `RigidBodyComponent` or an +`ActorComponent` given the component as its `stepper` is drawn `alpha` of +the way between its last two steps rather than jumping from one to the +next. + +**`HasFlutter3d`: a Flame game owns its 3D world.** Mixed into a +`FlameGame`, it gives the game its `scene`, `device`, `camera3d`, +`renderer` and `projector`, a `clearColor` and `renderSettings()` the frame +is drawn with, and a transparent background. The game builds its world in +`onOpen3d` and uses the renderer in `onRenderer3d`, each run once, after +the game has loaded, whichever order the widget or a test opens things in. +`Flutter3dFlameWidget(game: game)` then needs nothing else: `camera` and +`buildScene` are optional for such a game, and still work for any other. +River Sortie's `main.dart` went from the scene, the camera, the lens, the +haze, the projector, the renderer and the chase camera to the game alone. + +**A hidden parent hides its bridged children.** Flame does not draw the +children of a component it hides, and a child's scene node is not under its +parent's, so the child went on being drawn in 3D while the log it rode on +blinked. A bridged component now shows its node only while it and every +ancestor with `HasVisibility` are visible; `shownInFlame` answers that. + +**A child under a scaled parent is scaled by both.** Its place already +carried the parent's scale, and its node was scaled by its own alone, so the +model and the hitbox disagreed about its size. The node takes Flame's +absolute scale. The same two fixes reach `InstancedObject3dComponent`. + +**`ActorComponent` turns with its actor**, by the actor's yaw, and copies +the body only when the scene is authoritative. It and `RigidBodyComponent` +take a `size`, an `anchor` and an `elevation`, as `Object3dComponent` does: +a `RectangleHitbox()` on a bridged rigid body filled a size of nothing. + +**`Flutter3dFlameWidget` is tested.** Its two tests were skipped as hanging +under `flutter_test`; run directly, both finish in seconds. + +## 0.8.2 + +**`PhysicsStepComponent` steps the physics on Flame's clock.** A +`RigidBodyComponent` never steps the shared world, so every bridged game +wrote the same small component to do it once a frame: the arcade, the +example, each its own copy. It is public now. It calls `Dynamics.step`, then +an optional `afterStep` for anything that follows a body the solver just +moved (a trigger sensor riding on a solid body), then `CollisionWorld.update`, +which is what sends contacts to a `CollisionBridge`. Give it a priority below +the components that read the bodies. + +**`Flutter3dFlameWidget` closes the device it opened.** Without `existing` +it opens a `GraphicsDevice` and a `Renderer` of its own, and it never +released either: a page that came and went left a GPU context behind each +time. It now disposes both with itself, and still leaves a pair passed in +through `existing` to whoever passed it. It also takes its `BridgeClock` off +the game when it goes, so a game that outlives the widget stops calling +back into it, and a rebuild that hands in a different game moves the clock +to the new one. Before, the new game never got a clock, and the 3D layer +stopped following it. + +**A removed component hears no more contacts.** `CollisionBridge` relayed +to its component whether or not it was still in a game, so a ship removed +on one frame could still be told it hit something on the next. Flame's own +collision system does not call a removed component, and now the bridge does +not either. `CollisionBridge.detach()` clears the collider's listener, for +a collider that outlives its component. + +**`ActorSystemComponent` takes a `priority`** in its constructor, as every +other component does. + +**`CameraSyncComponent` runs a `CameraSyncController` as a component**, for +a game that would rather order the camera sync among its components than +tick it from `onTick`. The controller itself is unchanged. + +**`FlameInputBridge.onGameKeyEvent`** answers a `FlameGame`'s +`KeyboardEvents.onKeyEvent` in its own `KeyEventResult`. `onKeyEvent` answers +a component's `KeyboardHandler`, whose `true` means "keep propagating", and +every game that forwarded to it wrote the flip to `ignored`/`handled` by +hand. + +**A Flame turn on a ground plane is no longer drawn mirrored.** +`BridgePlane.rotationFor` took its sign from `Quaternion.rotated`, which +computes `q̄·v·q` and turns a vector by `-θ`, while a node is drawn through +its matrix, which turns by `+θ`. On `BridgePlane.ground` a Flame angle of ++0.5, clockwise on screen, was drawn anticlockwise; on a backdrop the two +sign flips cancelled and it came out right. `rotationFor` and `angleFor` now +both work through the matrix a node is drawn with, and a test checks where +the node is drawn on every plane, where the old ones only checked that an +angle survived the round trip, which it did either way. An +`Object3dComponent` syncing rotation `flameToScene` on the ground plane turns +the other way than it did, which is the way Flame means. + +**Flame is updated once a frame, not twice.** The bridge redrew the 3D layer +with a `setState` on the whole `Stack`, which rebuilt `GameWidget` every +frame, and `GameWidget` calls `game.update(0)` from its layout whenever it is +rebuilt: every frame the game updated twice and `onTick` saw a second call +with a `dt` of zero. Now only the 3D layer is rebuilt, and the `GameWidget` +is made once per game, so a rebuild from above (a HUD beside it) does not +reach Flame either. + +It asks for `flutter3d_physics` `^0.8.2`, which brings the cloth fix. + +## 0.8.1 + +**An example to start from.** `example/` is the smallest hybrid game: a Flame +HUD over a 3D yard, a cube whose Flame position drives its scene node, and a +crate that falls under `flutter3d_physics` onto a trigger pad and reports the +landing through Flame's own `onCollisionStart`. It runs the physics step as a +Flame component, so the order within a frame is the component tree's. + +The README is rewritten around what the bridge does. Nothing in `lib/` +changed. + +It asks for `flutter3d` and `flutter3d_physics` `^0.8.1`, which bring the +contact-shadow and folded-cloth fixes; the rest of its `flutter3d_*` +dependencies stay at `^0.8.0`. + +## 0.8.0 + +**Moves with the stack to 0.8.0**, whose `flutter3d_hardware` changes +`PassEncoder.bindTexture` to return `bool` and makes every backend forget its +bindings at `bindPipeline`. Nothing in this package changed. + +Its `flutter3d_*` dependencies ask for `^0.8.0`. + +## 0.7.1 + +**Released with the rest of the stack at 0.7.1.** Nothing in this package +changed. The release it resolves against builds from pub.dev again and no +longer crashes Metal on the first unlit draw. + +Its `flutter3d_*` dependencies ask for `^0.7.1`, and it asks for `vector_math` ^2.4.3. + +## 0.7.0 + +**A bridge to the Flame 2D game engine.** Flame draws its own layer, flutter3d +draws its own, and `Flutter3dFlameWidget` composites the two in one `Stack`, +Flame's `GameWidget` above flutter3d's `SceneSurface` — the same ordering +`apps/flutter3d_demo_platformer` already uses for a HUD over a bare +`SceneSurface`, and for the same reason: on the web the 3D surface is a +platform view that swallows pointer events, so whatever needs raw input has +to sit above it. A single `BridgeClock` component rides Flame's own game +loop rather than starting a second ticker, so the two engines' frames never +drift apart. + +`BridgePlane` is the one place a Flame `Vector2` and a flutter3d `Vector3` +are the same point — a ground plane or a vertical backdrop, chosen once and +shared by every bridged component rather than reinvented per caller. +`Object3dComponent` keeps a Flame `PositionComponent` and a flutter3d +`SceneNode` at the same place on one `BridgePlane`, in whichever direction a +`SyncDirection` names; `ActorComponent` and `RigidBodyComponent` extend it to +carry a `flutter3d_sim` actor's or a `flutter3d_physics` rigid body's own +position across the same seam, and `ActorSystemComponent` centralises the +one `ActorSystem.step` every `ActorComponent` in a game shares. +`CollisionBridge` re-fires flutter3d's collision events as Flame's own, +projecting a 3D contact onto the bridge's plane. `FlameInputBridge` reuses +`flutter3d_game`'s own `Bindings`/`InputState` — a bridged game and a native +one share one rebinding UI and one saved binding file, not two input models. +`CameraSyncController` keeps an orthographic flutter3d camera and Flame's own +2D viewfinder framed the same. diff --git a/packages/flame_flutter3d/LICENSE b/packages/flame_flutter3d/LICENSE new file mode 100644 index 00000000000..c602d8d40b4 --- /dev/null +++ b/packages/flame_flutter3d/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dmitrii Zolotov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flame_flutter3d/README.md b/packages/flame_flutter3d/README.md new file mode 100644 index 00000000000..dba764d4a6d --- /dev/null +++ b/packages/flame_flutter3d/README.md @@ -0,0 +1,163 @@ +# flame_flutter3d + +A bridge to the [Flame](https://pub.dev/packages/flame) 2D game engine. Flame +runs the game and draws its own layer; flutter3d draws the 3D one under it. +This package keeps the two in agreement on transforms, lifecycle, physics +contacts, input, the camera and the actor system, and neither engine drives +the other's renderer. + +```dart +class MyGame extends FlameGame with HasFlutter3d { + late final JetComponent jet; + + @override + void onOpen3d() { + scene.add(LightNode(name: 'sun')); + jet = JetComponent(node: SceneNode(), scene: scene); + add(jet); + add(ChaseCameraComponent(ChaseCamera( + camera: camera3d, + target: jet, + offset: Vector3(0, 10, 10), + lookOffset: Vector3(0, 0, -8), + ))); + } +} + +// In the widget tree: +Flutter3dFlameWidget(game: myGame) +``` + + +## One clock, two layers + +`Flutter3dFlameWidget` puts a flutter3d `SceneSurface` under Flame's own +`GameWidget` in one `Stack`. Flame is on top because it needs raw input. +Neither renderer is reimplemented. A `BridgeClock`, added once to the game, +calls back every frame after Flame's components have updated, and the 3D +frame is drawn from there. A bridged game runs on Flame's clock and no +other. + +A game with the `HasFlutter3d` mixin owns its 3D world. Its `scene`, +`device`, `camera3d`, `renderer` and `projector` are fields of the game; it +builds the world in `onOpen3d` and uses the renderer in `onRenderer3d`, each +once, after the game has loaded. The widget then needs only the game. A game +without the mixin passes a `camera` and a `buildScene` instead, as before. + +The world lives as long as the game, as Flame's components do: a game shown +again on a tab that comes back draws what it kept. `close3d()` lets the +device go, and `dispose()` calls it. A paused game is not drawn by itself, +and `redraw3d()` draws it once, for a pause menu that changes the sky. + + +## One plane, everywhere a point crosses + +`BridgePlane` is the one place a Flame `Vector2` and a flutter3d `Vector3` +are the same point. `BridgePlane.ground(height:)` is for a top-down game, +where Flame's `y` becomes flutter3d's `z`; `BridgePlane.backdrop(depth:)` is +for a side-scroller, where it stays `y`. Every bridged component takes one. + + +## What crosses + +`Object3dComponent` keeps a Flame `PositionComponent` and a scene node in +one place, in the direction a `SyncDirection` names. Flame's effects reach +the scene in the frame they happen, and a component nested under another +lands where Flame draws it. `elevation` lifts it off the plane; scale, +visibility, `opacity` and a `tint` cross as well, and a component under a +hidden parent is hidden in 3D too. A flipped component turns the way Flame +draws it, nested or not, and `TintEffect` moves the tint as Flame's +`ColorEffect` would a sprite's paint. A component that did not move writes +nothing, so it causes no shadow redraw. `visual` is a node under it that +the game turns and the bridge leaves alone. + +For many small things of one shape, `InstancedObject3dComponent` takes a +slot in a shared `InstancedMeshNode`, so a hundred shots are one draw. + +`ChaseCamera` follows a bridged component in perspective through +`flutter3d_sim`'s `CameraRig`, which can also shake it. +`CameraSyncController` keeps an orthographic camera and Flame's +`Viewfinder` framed the same; given an `eyeOffset`, it lets Flame's own +camera drive a perspective one, so `follow`, `setBounds` and zoom work as +in a flat game. A split screen is `viewport3d` and `moreViews3d` on the +game, and a `BridgeProjector` for each half. + +`BridgeProjector` says where a scene point is drawn, for a score over a +target, and which point of the plane is under a touch. Under a perspective +camera Flame's own tap test misses what the player sees, so a component +with `Tap3dCallbacks` hears a tap on its drawing, and the finger lifting or +held still, and `Taps3dComponent` hands each tap to the one the ray meets +first. An instance of a batch is tapped the same way. `debugHitboxes3d` draws every hitbox in +the scene, round its craft. + +`FlameInputBridge` translates Flame's keys, drags, touch stick +(`followJoystick`) and buttons (`bindButton`) into `flutter3d_game`'s +`Bindings` and `InputState`, the objects a native game's input writes. + +`RigidBodyComponent` and `ActorComponent` carry a body across. +`PhysicsStepComponent` and `ActorSystemComponent` step the shared world +once, in fixed steps, the game's own when it has `HasFixedStep`, and a +component handed its stepper is drawn between two steps. A body can be +`teleport`ed, and taken out of the world with its component. +`CollisionBridge` re-fires contacts as Flame's +`CollisionCallbacks`, and `ColliderRegistry` says which component a +collider belongs to. + +`ChunkStreamer` builds the pieces of a world that come into view and lets +go of those that leave it. `Particles3dComponent` runs a +`flutter3d_particles` pool on Flame's clock, additive for fire and +darkening for smoke. + +Beyond the plane, `Node3dComponent` is a Flame component in full 3D, moved +by `Move3dEffect`, `Rotate3dEffect` and `Scale3dEffect` on Flame's own +effect controllers; `SpriteBillboardComponent` stands a Flame `Sprite` or +`SpriteAnimation` in the scene facing the camera, or a line of Flame's +`TextPaint` written into a sprite by `BillboardAtlas.spriteOfText`; and an +`Object3dComponent` +that `follows` a `flame_forge2d` body draws Flame's own physics in 3D. + +A level drawn in Tiled is stood up by `TiledWorld3d`, each tile layer a +`CellGridComponent`, drawn as instances and given Flame hitboxes as its +properties say; `GridMover` walks a maze a cell at a time. +`KinematicBodyComponent` is a lift Flame's effects move, carrying whoever +stands on it, and `PlayerInputs` shares one keyboard between players. + +For whole genres there is more. `HasFixedStep` runs a game's own logic in +fixed steps, so a second of play comes out the same at any frame rate. +`ProjectedViewfinder` makes Flame's own events and conversions land on the +plane under the finger. `WrapSpace` is a world whose edges meet, with +ghosts drawn and hit across the seam; `CurvilinearSpace` bends Flame's +straight world along a road; `AtmosphereComponent` turns a day; +`CellGridComponent` is a shield worn away where it is hit; +`TrailComponent` draws a line behind a missile; `ModelAnimationComponent` +plays a model's clips; and `CharacterBodyComponent` steps a platformer's +runner. + +`BridgePriority` names the order all of this updates in, and the +components take it by default. + +Sound is in [`flame_flutter3d_audio`](https://pub.dev/packages/flame_flutter3d_audio), +a package of its own so that a game without sound does not carry SoLoud. + + +## Post-processing and the web + +The 3D layer is drawn in HDR, through flutter3d's post-processing chain: +bloom, SSAO and GTAO, screen-space reflections, depth of field, motion +blur, light shafts, volumetric fog, temporal anti-aliasing, a LUT and +four tone-mapping curves. `HasFlutter3d.renderSettings` is read before +every frame, so a game switches any of it on Flame's clock. + +A web build draws through WebGL2; built with +`--dart-define=FLUTTER3D_WEBGPU=true` it tries WebGPU first and falls back +to WebGL2. + + +## Examples + +`example/` is the smallest hybrid game. `examples/games/river_sortie` in +the Flame repository, a River Raid-style game, uses most of this package, +and the `flame_flutter3d` stories in Flame's examples show post-processing, +the shared loop and a Tiled map in 3D. The [documentation] says more. + +[documentation]: https://docs.flame-engine.org/latest/bridge_packages/flame_flutter3d/flame_flutter3d.html diff --git a/packages/flame_flutter3d/analysis_options.yaml b/packages/flame_flutter3d/analysis_options.yaml new file mode 100644 index 00000000000..c378b45f27b --- /dev/null +++ b/packages/flame_flutter3d/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flame_flutter3d/example/analysis_options.yaml b/packages/flame_flutter3d/example/analysis_options.yaml new file mode 100644 index 00000000000..52c18920db4 --- /dev/null +++ b/packages/flame_flutter3d/example/analysis_options.yaml @@ -0,0 +1,15 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml + +linter: + rules: + public_member_api_docs: false + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flame_flutter3d/example/lib/main.dart b/packages/flame_flutter3d/example/lib/main.dart new file mode 100644 index 00000000000..6a031dddd0b --- /dev/null +++ b/packages/flame_flutter3d/example/lib/main.dart @@ -0,0 +1,257 @@ +/// The smallest hybrid game: Flame on top, flutter3d underneath, one clock. +/// +/// flutter create --platforms=macos . # then switch on Flutter GPU, +/// flutter run -d macos # see pubspec.yaml +/// +/// Four things, each the least code that shows it: +/// +/// * a Flame HUD drawn over the 3D layer, which is the only way round the two +/// layers go: Flame is always on top; +/// * a cube Flame steers with the arrow keys, through `FlameInputBridge` and +/// an `Object3dComponent` that writes Flame's position into the scene; +/// * a crate the physics drops onto a pad, whose landing reaches Flame as an +/// ordinary `onCollisionStart`, through `CollisionBridge`; +/// * one clock: the physics is stepped by a plain Flame component, inside +/// Flame's own update, never by a timer of its own. +library; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter/services.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +void main() => runApp(const HybridApp()); + +class HybridApp extends StatelessWidget { + const HybridApp({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp( + title: 'Flame over flutter3d', + debugShowCheckedModeBanner: false, + home: HybridScreen(), + ); +} + +class HybridScreen extends StatefulWidget { + const HybridScreen({super.key}); + + @override + State createState() => _HybridScreenState(); +} + +class _HybridScreenState extends State { + // Made once and kept: a game made in `build` starts again on every rebuild + // and never draws a frame. + final HybridGame _game = HybridGame(); + + final CameraNode _camera = CameraNode(name: 'eye') + ..setPosition(0.0, 4.5, 7.5) + ..lookAt(Vector3(0.0, 0.5, 0.0)); + + @override + Widget build(BuildContext context) => Scaffold( + body: Flutter3dFlameWidget( + game: _game, + camera: _camera, + buildScene: (GraphicsDevice device) { + final scene = Scene()..add(_camera); + _game.buildWorld(device, scene); + return scene; + }, + ), + ); +} + +/// The game. A [TransparentFlameGame], not a plain `FlameGame`: Flame paints +/// an opaque black background by default, right over the 3D layer. +class HybridGame extends TransparentFlameGame with KeyboardEvents { + /// Flame's `y` becomes the scene's `z` on a floor half a metre up, where + /// the cube's centre travels. + static final BridgePlane floor = BridgePlane.ground(height: 0.5); + + final CollisionWorld collisionWorld = CollisionWorld(); + late final Dynamics dynamics = Dynamics(world: collisionWorld); + + final InputState input = InputState(); + late final FlameInputBridge inputBridge = FlameInputBridge( + bindings: Bindings({ + InputSource.key(LogicalKeyboardKey.arrowUp.keyId): GameAction.moveForward, + InputSource.key(LogicalKeyboardKey.arrowDown.keyId): GameAction.moveBack, + InputSource.key(LogicalKeyboardKey.arrowLeft.keyId): GameAction.moveLeft, + InputSource.key(LogicalKeyboardKey.arrowRight.keyId): + GameAction.moveRight, + }), + inputState: input, + ); + + late final Object3dComponent cube; + late final RigidBody crate; + late final Map _crateStart; + late final engine.Material _padMaterial; + + final TextComponent hud = TextComponent( + text: 'Arrow keys move the cube. Waiting for the crate.', + position: Vector2(16.0, 16.0), + ); + + /// How many times Flame has heard the crate land. + int landings = 0; + + double _sinceLanding = -1.0; + + /// The yard, the cube, the crate and the pad, once the device is open. + void buildWorld(GraphicsDevice device, Scene scene) { + MeshNode mesh(Shape shape, Vector4 colour) => MeshNode( + DeviceMesh.upload(device, shape.build()), + engine.Material(name: 'mesh', baseColor: colour, roughness: 0.6), + ); + + _padMaterial = engine.Material( + name: 'pad', + baseColor: Vector4(0.35, 0.4, 0.5, 1.0), + ); + scene + ..add( + mesh( + const PlaneShape(width: 12.0, depth: 12.0), + Vector4(0.6, 0.6, 0.58, 1), + ), + ) + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(2.0, 0.05, 2.0)).build(), + ), + _padMaterial, + )..setPosition(2.0, 0.025, 0.0), + ) + ..add( + LightNode(intensity: 2.5)..setLocalForward(Vector3(-0.4, -1.0, -0.3)), + ); + + // The floor stops the crate; the pad is a trigger just above it, because + // the solver rests a body on a surface and never inside it, so the floor + // itself never reports an overlap. + collisionWorld.addBox(Vector3(0.0, -0.5, 0.0), Vector3(12.0, 1.0, 12.0)); + final pad = collisionWorld.add( + Collider( + shape: CollisionBox(Vector3(1.0, 0.05, 1.0)), + position: Vector3(2.0, 0.05, 0.0), + kind: ColliderKind.trigger, + ), + ); + + // Flame steers this one: Flame's position is written into the scene. + cube = Object3dComponent( + node: mesh( + CuboidShape(size: Vector3.all(1.0)), + Vector4(0.3, 0.6, 0.95, 1), + ), + scene: scene, + plane: floor, + direction: SyncDirection.flameToScene, + position: Vector2(-2.0, 0.0), + ); + + // The physics moves this one, and Flame reads it. + crate = dynamics.add( + RigidBody( + world: collisionWorld, + shape: CollisionBox(Vector3.all(0.3)), + position: Vector3(2.0, 4.0, 0.0), + ), + ); + _crateStart = crate.save(); + final crateComponent = _CrateComponent( + body: crate, + node: mesh( + CuboidShape(size: Vector3.all(0.6)), + Vector4(0.8, 0.5, 0.25, 1), + ), + scene: scene, + plane: floor, + onLanded: _onLanded, + ); + CollisionBridge( + collider: crate.collider, + component: crateComponent, + // Who the other side is, for Flame. Only the pad has an answer; the + // floor is level geometry and reports nothing. + resolveOther: (Collider other) => other == pad ? cube : null, + ); + + addAll([ + // Stepped before the components that read the bodies it moves. + PhysicsStepComponent( + dynamics: dynamics, + world: collisionWorld, + priority: -100, + ), + cube, + crateComponent, + hud, + ]); + } + + void _onLanded() { + landings++; + _sinceLanding = 0.0; + hud.text = 'Flame heard the crate land ($landings)'; + _padMaterial.baseColor.setValues(0.3, 0.8, 0.4, 1.0); + } + + @override + void update(double dt) { + // The cube: Flame's own position, moved by the shared input. Forward is + // away from the camera, which is -z in the scene and so -y in Flame. + final axis = input.moveAxis; + cube.position.add(Vector2(axis.x, -axis.y) * (3.0 * dt)); + + // Drop the crate again a little after each landing. + if (_sinceLanding >= 0.0) { + _sinceLanding += dt; + if (_sinceLanding > 2.0) { + _sinceLanding = -1.0; + crate.restore(_crateStart); + _padMaterial.baseColor.setValues(0.35, 0.4, 0.5, 1.0); + } + } + super.update(dt); + } + + @override + KeyEventResult onKeyEvent( + KeyEvent event, + Set keysPressed, + ) => inputBridge.onGameKeyEvent(event, keysPressed); +} + +/// The crate: a physics body the scene and Flame both follow, and the +/// Flame-side collision callbacks `CollisionBridge` calls. +class _CrateComponent extends RigidBodyComponent { + _CrateComponent({ + required super.body, + required super.node, + required super.scene, + required super.plane, + required this.onLanded, + }); + + final void Function() onLanded; + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + onLanded(); + } +} diff --git a/packages/flame_flutter3d/example/pubspec.yaml b/packages/flame_flutter3d/example/pubspec.yaml new file mode 100644 index 00000000000..f42c114fa57 --- /dev/null +++ b/packages/flame_flutter3d/example/pubspec.yaml @@ -0,0 +1,46 @@ +name: flame_flutter3d_example +description: "The smallest hybrid game: a Flame HUD over a 3D yard, a cube Flame steers, and a crate whose landing Flame hears." +publish_to: 'none' +version: 0.1.0+1 +resolution: workspace + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + flame: ^2.0.0-dev.0 + + # The bridge: two layers, one clock, transforms, collisions and input. + flame_flutter3d: ^0.9.0-dev.0 + + flutter: + sdk: flutter + + # The renderer and the scene graph. + flutter3d: ^0.8.3 + + # `Bindings`, the key table the input bridge writes through. + flutter3d_game: ^0.8.0 + + # `InputState` and `GameAction`, and the physics it re-exports. + flutter3d_sim: ^0.8.1 + +dev_dependencies: + flame_lint: ^1.4.4-dev.0 + + # A device with no GPU under it, so a test can build the world the way the + # application does. + flutter3d_cpu: ^0.8.0 + + flutter_test: + sdk: flutter + +# **No platform folders here on purpose**, as in `packages/flutter3d_app/example`: +# this exists to be analysed and to be copied. In the project it is copied +# into, `flutter create --platforms=macos .` gives it a window, and then +# Flutter GPU has to be switched on for that platform, or the 3D layer draws +# nothing: `FLTEnableFlutterGPU` and `FLTEnableImpeller` in +# `macos/Runner/Info.plist` (and `ios/Runner/Info.plist`), +# `io.flutter.embedding.android.EnableFlutterGPU` in `AndroidManifest.xml`. +flutter: + uses-material-design: true diff --git a/packages/flame_flutter3d/example/test/hybrid_test.dart b/packages/flame_flutter3d/example/test/hybrid_test.dart new file mode 100644 index 00000000000..8a3d5fa18f4 --- /dev/null +++ b/packages/flame_flutter3d/example/test/hybrid_test.dart @@ -0,0 +1,51 @@ +/// The example's world, built the way the app builds it and stepped the way +/// Flame steps it: `update(dt)` called directly, no widget tree. +library; + +import 'package:flame_flutter3d_example/main.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +HybridGame _newGame() { + final it = cpuTestDevice(width: 32, height: 24); + return HybridGame()..buildWorld(it.device, Scene()); +} + +void _run(HybridGame game, int steps) { + for (var i = 0; i < steps; i++) { + game.update(1 / 60); + } +} + +void main() { + test('the crate falls onto the pad and Flame hears it land', () { + final game = _newGame(); + expect(game.landings, 0); + + // Four metres at one g is under a second; two is plenty. + _run(game, 120); + + expect(game.landings, 1); + expect(game.crate.position.y, lessThan(1.0)); + expect(game.hud.text, contains('land')); + }); + + test('an arrow key moves the cube in the scene, through Flame', () { + final game = _newGame(); + // One frame first: the bridge writes Flame's position into the node on + // update, so until then the node is still at the origin. + _run(game, 1); + final before = game.cube.node.readPosition().x; + expect(before, closeTo(-2.0, 1e-6)); + + game.input.press(GameAction.moveRight); + _run(game, 30); + + // Flame's position moved, and the flameToScene bridge wrote it into + // the node the 3D layer draws. + expect(game.cube.position.x, greaterThan(-2.0)); + expect(game.cube.node.readPosition().x, greaterThan(before)); + }); +} diff --git a/packages/flame_flutter3d/lib/flame_flutter3d.dart b/packages/flame_flutter3d/lib/flame_flutter3d.dart new file mode 100644 index 00000000000..8a099ac75f6 --- /dev/null +++ b/packages/flame_flutter3d/lib/flame_flutter3d.dart @@ -0,0 +1,74 @@ +/// A bridge to the Flame 2D game engine. +/// +/// Flame draws its own layer, flutter3d draws its own, and this package +/// keeps the two reconciled: transforms and lifecycle +/// ([Flutter3dFlameWidget], [BridgePlane], [Object3dComponent]), the actor +/// system ([ActorComponent], [ActorSystemComponent]), physics +/// ([RigidBodyComponent], [PhysicsStepComponent], [CollisionBridge]), input +/// ([FlameInputBridge]) and camera ([CameraSyncController], +/// [CameraSyncComponent], [ChaseCamera], [BridgeProjector]) and an endless +/// world built piece by piece ([ChunkStreamer]). The `flame_flutter3d` +/// stories in Flame's examples show one mechanism each. +library; + +import 'package:flame_flutter3d/flame_flutter3d.dart' + show + Flutter3dFlameWidget, + BridgePlane, + Object3dComponent, + ActorComponent, + ActorSystemComponent, + RigidBodyComponent, + PhysicsStepComponent, + CollisionBridge, + FlameInputBridge, + CameraSyncController, + CameraSyncComponent, + ChaseCamera, + BridgeProjector, + ChunkStreamer; + +export 'src/animation/model_animation_component.dart'; +export 'src/camera/camera_sync_component.dart'; +export 'src/camera/camera_sync_controller.dart'; +export 'src/camera/chase_camera.dart'; +export 'src/camera/projected_viewfinder.dart'; +export 'src/camera/view_camera.dart'; +export 'src/debug/hitboxes3d.dart'; +export 'src/ecs/actor_component.dart'; +export 'src/ecs/actor_system_component.dart'; +export 'src/ecs/instanced_actor_component.dart'; +export 'src/host/bridge_clock.dart'; +export 'src/host/bridge_priority.dart'; +export 'src/host/flutter3d_flame_widget.dart'; +export 'src/host/has_fixed_step.dart'; +export 'src/host/has_flutter3d.dart'; +export 'src/host/step_clock.dart'; +export 'src/host/transparent_flame_game.dart'; +export 'src/host/updates_at_root.dart'; +export 'src/input/flame_input_bridge.dart'; +export 'src/input/taps3d.dart'; +export 'src/particles/particles3d_component.dart'; +export 'src/physics/character_body_component.dart'; +export 'src/physics/collider_registry.dart'; +export 'src/physics/collision_bridge.dart'; +export 'src/physics/kinematic_body_component.dart'; +export 'src/physics/physics_step_component.dart'; +export 'src/physics/rigid_body_component.dart'; +export 'src/transform/billboard_atlas.dart'; +export 'src/transform/bridge_space.dart'; +export 'src/transform/bridged3d.dart'; +export 'src/transform/instanced_object3d_component.dart'; +export 'src/transform/node3d_component.dart'; +export 'src/transform/object3d_component.dart'; +export 'src/transform/plane.dart'; +export 'src/transform/projector.dart'; +export 'src/transform/sprite_billboard_component.dart'; +export 'src/world/atmosphere_component.dart'; +export 'src/world/cell_grid_component.dart'; +export 'src/world/chunk_streamer.dart'; +export 'src/world/fixture_visuals_component.dart'; +export 'src/world/grid_mover.dart'; +export 'src/world/tiled_world.dart'; +export 'src/world/trail_component.dart'; +export 'src/world/wrap_space.dart'; diff --git a/packages/flame_flutter3d/lib/src/animation/model_animation_component.dart b/packages/flame_flutter3d/lib/src/animation/model_animation_component.dart new file mode 100644 index 00000000000..b61b600b9c1 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/animation/model_animation_component.dart @@ -0,0 +1,118 @@ +import 'package:flame/components.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A model's animations played on Flame's clock: a runner running, a frog +/// crouching to hop, a door swinging. +/// +/// **Paused with the game.** An `AnimationPlayer` ticked from anywhere +/// else went on running while Flame was paused, and a paused game with its +/// characters still walking is not paused. Added under the bridged +/// component whose node wears the model, this advances the player in its +/// own `update`, which Flame does not call while paused. +/// +/// [play] changes the clip by name, crossfading over [crossFade] seconds +/// unless told otherwise; asking for the clip already playing does nothing, +/// so a game can ask every frame for the clip its state wants. +class ModelAnimationComponent extends Component { + ModelAnimationComponent(this.player, {this.crossFade = 0.15, String? start}) + : _current = start { + if (start != null) { + player.playNamed(start); + } + } + + /// The player a loaded model instance came with: `ModelInstance.player`. + final AnimationPlayer player; + + /// How long a change of clip blends, in seconds. + final double crossFade; + + String? _current; + + /// The clip playing, by name, or null before the first [play]. + String? get current => _current; + + /// Whether the model has a clip called [name]. + bool has(String name) => player.clipNames.contains(name); + + /// Plays the clip called [name], blending from the one playing over + /// [fade] seconds, or [crossFade]. False when there is no such clip. + /// + /// [restart] plays it from its start even when it is the clip playing: a + /// second jump, a second hit. Asking for the clip playing did nothing, so + /// a clip that plays once could be played once a game. + bool play(String name, {double? fade, bool restart = false}) { + if (name == _current) { + if (restart) { + // The player rewinds only on a change of clip: seek it back. + player + ..seek(0.0) + ..play(); + } + return true; + } + final found = _current == null + ? player.playNamed(name) + : player.crossFadeToNamed(name, duration: fade ?? crossFade); + if (found) { + _current = name; + } + return found; + } + + @override + void update(double dt) { + super.update(dt); + player.update(dt); + } +} + +/// A mesh node that shows [frames] in turn, [framesPerSecond] of them a +/// second: an invader's two poses, a flag in four. +/// +/// For animation that is a handful of shapes rather than a skeleton, which +/// is how most of the cartridge era moved. The meshes are shared; only the +/// one [node] draws changes, and only when the frame does. +class MeshFlipbookComponent extends Component { + MeshFlipbookComponent({ + required this.node, + required this.frames, + this.framesPerSecond = 2.0, + }) : assert(frames.isNotEmpty, 'a flipbook of nothing shows nothing'); + + /// The node whose mesh is swapped. + final MeshNode node; + + /// What it shows, in order, looping. + final List frames; + + /// How many frames a second. + double framesPerSecond; + + double _time = 0.0; + int _shown = -1; + + /// The index of the frame showing. + int get frame => _shown; + + @override + void onMount() { + super.onMount(); + _show(0); + } + + @override + void update(double dt) { + super.update(dt); + _time += dt; + _show((_time * framesPerSecond).floor() % frames.length); + } + + void _show(int index) { + if (index == _shown) { + return; + } + _shown = index; + node.mesh = frames[index]; + } +} diff --git a/packages/flame_flutter3d/lib/src/camera/camera_sync_component.dart b/packages/flame_flutter3d/lib/src/camera/camera_sync_component.dart new file mode 100644 index 00000000000..74610243200 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/camera/camera_sync_component.dart @@ -0,0 +1,58 @@ +/// [CameraSyncComponent] runs a [CameraSyncController] from Flame's own +/// game loop. +library; + +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/camera/camera_sync_controller.dart'; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/updates_at_root.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show SyncDirection; + +/// Calls [controller]'s [CameraSyncController.advance] once a frame, as a +/// Flame component. +/// +/// **The one-line wrapper [CameraSyncController]'s doc leaves to the +/// caller, written once.** The controller stays a plain class, testable +/// without a game, and a host that already ticks it from +/// `Flutter3dFlameWidget.onTick` keeps doing that. This is for a game that +/// would rather order the sync among its components. +/// +/// **Ordered after whatever moves the authoritative side, by default.** +/// Flame updates components by ascending priority. Flowing Flame to the +/// scene, the viewfinder is moved by Flame's own camera, following its +/// target after everything else, so this runs after that camera +/// ([BridgePriority.afterFlameCamera]); synced before it, the 3D camera +/// trailed `camera.follow()` by a frame. Flowing the scene to Flame, it runs +/// after the craft and before Flame's camera reads the viewfinder +/// ([BridgePriority.camera]). +/// +/// **Wherever it is added.** A priority orders siblings only, and Flame's +/// camera is a sibling of the world, not of what is in it: added to the +/// world, where a game adds its components, this ran inside the world's +/// update, before the camera, whatever its number, and the 3D camera trailed +/// `camera.follow()` by a frame again — the lag the priority had been chosen +/// to remove. Flowing Flame to the scene, it syncs from the game's root +/// wherever it is added: see [UpdatesAtRoot]. +final class CameraSyncComponent extends Component with UpdatesAtRoot { + CameraSyncComponent({required this.controller, int? priority}) + : super( + priority: + priority ?? + switch (controller.direction) { + SyncDirection.flameToScene => BridgePriority.afterFlameCamera, + SyncDirection.sceneToFlame => BridgePriority.camera, + }, + ); + + /// The controller advanced every [update]. + final CameraSyncController controller; + + /// Only flowing Flame to the scene: the other way, it runs before Flame's + /// camera, which inside the world it does anyway. + @override + bool get needsRoot => controller.direction == SyncDirection.flameToScene; + + @override + void rootUpdate(double dt) => controller.advance(dt); +} diff --git a/packages/flame_flutter3d/lib/src/camera/camera_sync_controller.dart b/packages/flame_flutter3d/lib/src/camera/camera_sync_controller.dart new file mode 100644 index 00000000000..3453fa5554e --- /dev/null +++ b/packages/flame_flutter3d/lib/src/camera/camera_sync_controller.dart @@ -0,0 +1,241 @@ +/// A flutter3d [CameraNode] and a Flame [Viewfinder] kept describing the +/// same view, one side authoritative each frame. +/// +/// **Why a camera needs its own bridge instead of reusing +/// `Object3dComponent`.** A camera is not a prop: nothing draws it, so it +/// never needs a `Scene` entry or a mount/unmount lifecycle, and it carries +/// a second number a +/// `PositionComponent` does not — how much of the world is visible — which +/// `Object3dComponent` has no field for. What the two do share is the +/// position half of the problem and the question of which side writes, which +/// is why this reads position through the same [BridgePlane] and reuses +/// [SyncDirection] rather than defining its own. +library; + +import 'package:flame/camera.dart' show Viewfinder; +import 'package:flame_flutter3d/src/camera/camera_sync_component.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show SyncDirection, Object3dComponent; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:vector_math/vector_math.dart' show Quaternion, Vector3; + +/// Reconciles a flutter3d [CameraNode] with a Flame [Viewfinder], on one +/// [BridgePlane], one [direction] deciding who writes each frame. +/// +/// **Not a Flame `Component`.** Nothing here needs Flame's lifecycle +/// (`onLoad`, `onMount`) or its render tree — it is a plain reconciliation +/// step, called from wherever a bridged game already ticks its other +/// controllers, the same way `OrbitController` is a plain Dart class with +/// its own `advance`. A caller that wants this driven by Flame's own update +/// loop adds a [CameraSyncComponent]; this class does not presume one. +/// +/// **Takes a [Viewfinder], not a `CameraComponent`.** A [Viewfinder]'s +/// `position`/`zoom`/`angle` setters only ever touch its own `Transform2D` +/// — nothing in them reaches for `camera.viewport`, so a `Viewfinder()` is +/// fully usable, and testable, unmounted. Requiring a mounted +/// `CameraComponent` here would mean this controller's own tests need a +/// `GameWidget`, and — per this package's own test notes — mounting one +/// under `flutter_test` currently hangs. A caller that already has a +/// `CameraComponent` passes its `viewfinder` field straight through. +/// +/// **Reuses [SyncDirection] rather than a second enum.** The choice this +/// makes — which side is the source of truth this frame — is exactly the +/// choice [Object3dComponent] already names, and a bridged game routing a +/// camera and its props through two differently-spelled but identically +/// shaped enums would be a distinction with no difference, just a second +/// `switch` a reader has to convince themselves matches the first. +/// +/// **The zoom/height reconciliation assumes an orthographic lens, and says +/// so rather than pretending otherwise.** Flame's [Viewfinder.zoom] is a +/// single scalar: pixels on screen per world unit. An +/// [OrthographicProjection]'s `height` is the same kind of number — how much +/// of the world is visible, independent of how far the camera stands from +/// it — so the two have one honest correspondence. A [PerspectiveProjection] +/// has no such number: how much of the world a perspective camera shows +/// depends on both its field of view *and* its distance from whatever it is +/// looking at, and no single scalar copied onto [Viewfinder.zoom] would mean +/// the same thing twice in a row as that distance changed. So this class +/// checks [CameraNode.projection] with `is OrthographicProjection` every +/// frame rather than requiring the type up front: a camera is free to swap +/// lenses (the same freedom [CameraNode.projection] itself is mutable for), +/// and when it is not orthographic this controller still keeps position in +/// sync and simply leaves whichever side's zoom/height it would have written +/// alone, rather than writing a number that does not mean what the other +/// side thinks it means. +/// +/// **The zoom ↔ height mapping is `zoom = 1 / height`, a chosen convention, +/// not a pixel-exact one.** The two numbers move the right way relative to +/// each other with no further data: zooming in (raising [Viewfinder.zoom]) +/// shrinks the visible world, and so should [OrthographicProjection.height] +/// falling — which the reciprocal does, and does invertibly, so a value +/// written by one side and read back by the other round-trips exactly. What +/// this deliberately does *not* attempt is pixel-accurate agreement — "N +/// world units always exactly fill the viewport's height in both engines at +/// once" — because that would need the Flame viewport's own pixel size, +/// which an unmounted [Viewfinder] does not carry and which this class was +/// built to work without. A caller that needs that tighter guarantee scales +/// [Viewfinder.zoom] by its viewport's pixel height on its own before or +/// after calling [advance]; this class only guarantees the two lenses agree +/// with each other under its own convention, consistently, every frame. +final class CameraSyncController { + CameraSyncController({ + required this.camera, + required this.viewfinder, + required this.plane, + this.direction = SyncDirection.sceneToFlame, + this.viewportHeight, + this.syncAngle = false, + Vector3? eyeOffset, + }) : _base = camera.readRotation(), + eyeOffset = eyeOffset?.clone(); + + /// Where a perspective camera stands from the point it looks at, in the + /// scene, at a zoom of one: `(0, 12, 10)` is above and behind a ground + /// plane's point. Flowing Flame to the scene with this given, the camera + /// looks at [Viewfinder.position] on [plane] from there, the offset divided + /// by [Viewfinder.zoom] and, with [syncAngle], turned by + /// [Viewfinder.angle] about the plane's normal. + /// + /// **Flame's camera, driving a perspective one.** Without it the camera was + /// put at the viewfinder's point, on the plane, and nothing Flame's camera + /// does reached a perspective lens: `follow` with its `maxSpeed`, + /// `setBounds`, a `MoveEffect` or a `ScaleEffect` on the viewfinder. With + /// it they all do, as they would a flat Flame game. + /// + /// **Under an orthographic lens it is the angle of view**: the camera + /// looks along it from as far as it says, and the zoom stays the lens's + /// height. An isometric board, a pyramid of cubes seen from a corner, is + /// an offset of equal parts on all three axes. + final Vector3? eyeOffset; + + final Vector3 _looked = Vector3.all(double.nan); + double _lookedZoom = double.nan; + double _lookedAngle = double.nan; + + /// The Flame viewport's height in logical pixels, read every frame; when + /// given, the two lenses agree to the pixel. + /// + /// **Pixel-exact, not a convention.** Without it the zoom is the + /// reciprocal of the height, which moves the right way and agrees with + /// nothing on screen. With it, [Viewfinder.zoom] is pixels per world unit, + /// the viewport's height over [OrthographicProjection.height], so a + /// 224-by-256 field fills the same pixels in both layers at any window + /// size: what a Space Invaders cabinet drawn in both engines needs. + final double Function()? viewportHeight; + + /// Whether Flame's [Viewfinder.angle] and the camera's turn about the + /// plane's normal are kept the same: a screen that rolls. + /// + /// The camera's rotation when this controller was made is its rest, and + /// the angle is a turn about the plane's normal on top of it. + final bool syncAngle; + + final Quaternion _base; + + /// Takes the camera's rotation now as its rest: for a camera turned with + /// `lookAt` after this controller was made, whose rest was otherwise the + /// turn it had before. + void takeRest() => _base.setFrom(camera.readRotation()); + + /// The flutter3d camera this controller reconciles. + final CameraNode camera; + + /// The Flame viewfinder kept in step with [camera]. + final Viewfinder viewfinder; + + /// The 2D↔3D axis mapping [camera]'s position is read and written through + /// — the same [BridgePlane] every other bridged component in this scene + /// shares, so a 2D point means the same 3D point everywhere. + final BridgePlane plane; + + /// Which side is authoritative each frame. See [SyncDirection]. + final SyncDirection direction; + + /// Copies one frame's worth of state from whichever side [direction] names + /// as authoritative onto the other. + /// + /// Takes [dt] to match the shape every other per-frame controller in this + /// repo has — `OrbitController.advance`, `Object3dComponent.update` — so a + /// host loop can call every controller it owns the same way without + /// asking which ones actually use the elapsed time. This one does not: a + /// copy has no notion of speed, unlike `OrbitController`'s own `advance`, + /// which is easing a turn already in flight. + void advance(double dt) { + switch (direction) { + case SyncDirection.sceneToFlame: + _sceneToFlame(); + case SyncDirection.flameToScene: + _flameToScene(); + } + } + + /// Pixels per world unit for a view [height] units tall. + double _zoomFor(double height) { + final pixels = viewportHeight?.call(); + return pixels == null ? 1.0 / height : pixels / height; + } + + /// The view height in world units that [zoom] shows. + double _heightFor(double zoom) { + final pixels = viewportHeight?.call(); + return pixels == null ? 1.0 / zoom : pixels / zoom; + } + + void _sceneToFlame() { + viewfinder.position = plane.to2d(camera.readPosition()); + final projection = camera.projection; + if (projection is OrthographicProjection) { + viewfinder.zoom = _zoomFor(projection.height); + } + if (syncAngle) { + final rest = Quaternion.copy(_base)..inverse(); + viewfinder.angle = plane.angleFor(camera.readRotation() * rest); + } + } + + /// Written only when the viewfinder moved: a camera written is a changed + /// node, and a still one had its shadows drawn again every frame. + /// + /// An orthographic lens looks along the offset from as far as it is + /// given, and its zoom is its height instead: nearer would not show less. + void _lookFrom(Vector3 offset, {required bool byZoom}) { + final at = viewfinder.position; + final zoom = byZoom ? viewfinder.zoom : 1.0; + final angle = syncAngle ? viewfinder.angle : 0.0; + if (_looked.x == at.x && + _looked.y == at.y && + _lookedZoom == zoom && + _lookedAngle == angle) { + return; + } + _looked.setValues(at.x, at.y, 0.0); + _lookedZoom = zoom; + _lookedAngle = angle; + final target = plane.to3d(at); + final eye = plane.rotationFor(angle).rotated(offset / zoom)..add(target); + camera + ..setPositionFrom(eye) + ..lookAt(target); + } + + void _flameToScene() { + final offset = eyeOffset; + final projection = camera.projection; + if (offset != null) { + _lookFrom(offset, byZoom: projection is! OrthographicProjection); + } else { + camera.setPositionFrom(plane.to3d(viewfinder.position)); + } + if (projection is OrthographicProjection) { + final height = _heightFor(viewfinder.zoom); + // A lens made only when the zoom moved, not every frame. + if (height != projection.height) { + camera.projection = projection.copyWith(height: height); + } + } + if (syncAngle && offset == null) { + camera.setRotation(plane.rotationFor(viewfinder.angle) * _base); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/camera/chase_camera.dart b/packages/flame_flutter3d/lib/src/camera/chase_camera.dart new file mode 100644 index 00000000000..aedf6ac50fe --- /dev/null +++ b/packages/flame_flutter3d/lib/src/camera/chase_camera.dart @@ -0,0 +1,100 @@ +import 'package:flame/components.dart' show Component; +import 'package:flame_flutter3d/flame_flutter3d.dart' show CameraSyncController; +import 'package:flame_flutter3d/src/camera/camera_sync_controller.dart' + show CameraSyncController; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart' show CollisionWorld; +import 'package:flutter3d_sim/flutter3d_sim.dart' show CameraRig; +import 'package:vector_math/vector_math.dart' show Vector3; + +/// A camera that follows a bridged component from where [offset] puts it, +/// looking at where [lookOffset] points: behind and above a jet, looking up +/// the river ahead of it. +/// +/// **For a perspective camera, where [CameraSyncController] cannot help.** +/// That reconciles a Flame viewfinder's zoom with an orthographic height; +/// a perspective chase has nothing of Flame's to reconcile, only a target +/// to keep in frame, and every game that had one wrote it by hand. +/// +/// **Part way across.** Along the plane's own x axis the camera follows +/// the target by [followAcross] and aims by [lookAcross], fractions of the +/// target's x: a camera locked to a craft's every dodge turns the whole +/// world with it, and one that does not follow at all loses a craft off a +/// narrow screen. Everything else follows the target in full. +/// +/// **Stiff or springy.** With [stiffness] at zero the camera is exactly +/// where the offsets say every frame. Above zero it closes on that place +/// exponentially, [stiffness] being how many times its distance it closes +/// per second, and the first [advance] still puts it there outright. +/// +/// **Through `flutter3d_sim`'s `CameraRig`.** The easing, a knock, a shake +/// and the pull out of walls are the rig's, written once for every chasing +/// camera: [rig] is there to shake when the craft is hit, and a `world` +/// with walls in it keeps the camera out of them. Without one the camera +/// has nothing to be kept out of. +final class ChaseCamera { + ChaseCamera({ + required this.camera, + required this.target, + required this.offset, + required this.lookOffset, + this.followAcross = 1.0, + this.lookAcross = 1.0, + this.stiffness = 0.0, + CollisionWorld? world, + }) : rig = CameraRig(world: world ?? CollisionWorld()); + + final CameraNode camera; + final Object3dComponent target; + + /// From the target's scene position to the camera. + final Vector3 offset; + + /// From the target's scene position to the point the camera looks at. + final Vector3 lookOffset; + + final double followAcross; + final double lookAcross; + final double stiffness; + + /// What eases the camera, and what shakes it: `rig.shake(0.4)` when the + /// craft goes down. + final CameraRig rig; + + /// A closing rate high enough that a stiff camera is where it should be + /// after any frame, through the same easing a springy one goes through. + static const double _rigid = 1e4; + + /// Moves the camera for this frame. Call it once the target has moved, + /// from `Flutter3dFlameWidget.onTick` or through [ChaseCameraComponent]. + void advance(double dt) { + final at = target.scenePosition; + final eye = at + offset + ..x = at.x * followAcross + offset.x; + final look = at + lookOffset + ..x = at.x * lookAcross + lookOffset.x; + rig.place( + desiredEye: eye, + desiredTarget: look, + lag: stiffness > 0.0 ? stiffness : _rigid, + dt: dt, + ); + camera + ..setPositionFrom(rig.eye) + ..lookAt(rig.target); + } +} + +/// A [ChaseCamera] run as a Flame component, for a game that would rather +/// order it by priority than call it from a tick. Give it a priority above +/// whatever moves the target, so it follows this frame's move. +final class ChaseCameraComponent extends Component { + ChaseCameraComponent(this.chase, {super.priority = BridgePriority.camera}); + + final ChaseCamera chase; + + @override + void update(double dt) => chase.advance(dt); +} diff --git a/packages/flame_flutter3d/lib/src/camera/projected_viewfinder.dart b/packages/flame_flutter3d/lib/src/camera/projected_viewfinder.dart new file mode 100644 index 00000000000..945c567fdf3 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/camera/projected_viewfinder.dart @@ -0,0 +1,117 @@ +import 'dart:math' as math; +import 'dart:ui' show Offset, Rect; + +import 'package:flame/camera.dart'; +import 'package:flame/components.dart' show Vector2; + +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flame_flutter3d/src/transform/projector.dart'; + +/// A Flame [Viewfinder] that maps the screen to the game's plane through the +/// 3D camera, so Flame's own events land where the player sees things. +/// +/// **What Flame gets wrong under a perspective camera.** A `CameraComponent` +/// turns a point on the screen into a world point with its viewfinder's +/// affine transform: an offset, a zoom, a turn. A perspective 3D camera does +/// not draw the plane that way, so a tap on a craft reached Flame as a +/// point metres away from it, and a component's `TapCallbacks`, a +/// `camera.globalToLocal` in the game's own code, and Flame's hit test all +/// missed. Here a screen point becomes the point of [plane] under it, found +/// by [projector], and a plane point becomes where it is drawn. +/// +/// **The sky is the horizon.** A point that meets no plane came back as NaN, +/// and Flame's `World` takes every point: its tap handlers were handed NaN, +/// and a drag that strayed above the horizon moved its component to NaN for +/// good. It now comes back as the plane point out at the horizon in that +/// direction, which is far, finite, and where a drag would have been going. +/// +/// **Any viewport.** Flame hands a viewfinder points in its viewport's own +/// frame, and the projector works in the canvas both layers share; a +/// `FixedResolutionViewport`, or one placed off the corner, put every tap +/// somewhere else. Points are brought into the canvas first, and back. +/// +/// **Events and conversions, not drawing.** Flame still draws its world +/// through the affine transform; a bridged game draws its world in 3D and +/// keeps Flame's drawing to the viewport, where this changes nothing. +/// +/// camera = CameraComponent( +/// world: world, +/// viewfinder: ProjectedViewfinder(projector: projector, plane: plane), +/// ); +class ProjectedViewfinder extends Viewfinder { + ProjectedViewfinder({required this.projector, required this.plane}); + + /// The plane the game plays on. + final BridgePlane plane; + + /// Between the 3D camera and the screen. + final BridgeProjector projector; + + Viewport? get _viewport => switch (parent) { + final CameraComponent camera => camera.viewport, + _ => null, + }; + + @override + Vector2 globalToLocal(Vector2 point, {Vector2? output}) { + final canvas = _viewport?.localToGlobal(point) ?? point; + final onPlane = projector.onPlaneOrHorizon(canvas, plane); + final result = output ?? Vector2.zero(); + if (onPlane == null) { + return result..setValues(double.nan, double.nan); + } + return result..setFrom(onPlane); + } + + /// **What the 3D camera shows, not what the affine transform would.** + /// Flame's `visibleWorldRect`, which `canSee` and a `setBounds` that + /// minds the viewport read, came from the viewfinder's offset and zoom, + /// and under a perspective lens was a rectangle nobody was looking at. + /// Here it is the box round the plane points under the viewport's four + /// corners, the horizon standing in for the sky. + @override + Rect computeVisibleRect() { + final size = _viewport?.virtualSize ?? projector.viewSize(); + final corners = [ + for (final (x, y) in <(double, double)>[ + (0.0, 0.0), + (size.x, 0.0), + (0.0, size.y), + (size.x, size.y), + ]) + globalToLocal(Vector2(x, y)), + ].where((p) => p.x.isFinite && p.y.isFinite).toList(); + if (corners.isEmpty) { + return Rect.zero; + } + return Rect.fromPoints( + Offset( + corners.map((p) => p.x).reduce(math.min), + corners.map((p) => p.y).reduce(math.min), + ), + Offset( + corners.map((p) => p.x).reduce(math.max), + corners.map((p) => p.y).reduce(math.max), + ), + ); + } + + /// Worked out afresh every frame: the 3D camera moves without Flame's + /// transform changing, and Flame keeps the rectangle until it does. + @override + void update(double dt) { + super.update(dt); + // ignore: invalid_use_of_internal_member, the cache Flame keeps for it. + visibleRect = null; + } + + @override + Vector2 localToGlobal(Vector2 point, {Vector2? output}) { + final screen = projector.toScreen(plane.to3d(point)); + final result = output ?? Vector2.zero(); + if (screen == null) { + return result..setValues(double.nan, double.nan); + } + return result..setFrom(_viewport?.globalToLocal(screen) ?? screen); + } +} diff --git a/packages/flame_flutter3d/lib/src/camera/view_camera.dart b/packages/flame_flutter3d/lib/src/camera/view_camera.dart new file mode 100644 index 00000000000..112d90ffe36 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/camera/view_camera.dart @@ -0,0 +1,74 @@ +import 'package:flame/components.dart' show Component; +import 'package:flame_flutter3d/src/camera/chase_camera.dart' show ChaseCamera; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart' show CollisionWorld; +import 'package:flutter3d_sim/flutter3d_sim.dart' show CameraRig; +import 'package:vector_math/vector_math.dart' show Vector3; + +/// A camera eased towards wherever a function says it should be. +/// +/// **For what one component cannot say.** [ChaseCamera] follows a bridged +/// component; a camera over a co-op party follows all of it, and where it +/// should be is worked out from every hero at once — by the game's own +/// framing, which may also be a rule of the simulation. [view] is that +/// answer, asked once a frame: it writes the eye and the point looked at +/// into the two vectors it is handed, or answers false while there is +/// nothing to look at yet. +/// +/// Through the same [CameraRig] as [ChaseCamera]: the easing, the shake and +/// the pull out of walls are the rig's. +final class ViewCamera { + ViewCamera({ + required this.camera, + required this.view, + this.stiffness = 4.0, + CollisionWorld? world, + }) : rig = CameraRig(world: world ?? CollisionWorld()); + + final CameraNode camera; + + /// Where the camera wants to be this frame, written into `eye` and + /// `target`; false leaves the camera where it is. + final bool Function(Vector3 eye, Vector3 target) view; + + /// How many times its distance the camera closes per second; zero puts it + /// there outright. + final double stiffness; + + /// What eases the camera, and what shakes it. + final CameraRig rig; + + final Vector3 _eye = Vector3.zero(); + final Vector3 _target = Vector3.zero(); + + /// Moves the camera for this frame. + void advance(double dt) { + if (!view(_eye, _target)) { + return; + } + rig.place( + desiredEye: _eye, + desiredTarget: _target, + lag: stiffness > 0.0 ? stiffness : 1e4, + dt: dt, + ); + camera + ..setPositionFrom(rig.eye) + ..lookAt(rig.target); + } +} + +/// A [ViewCamera] run as a Flame component, after whatever moves what it +/// frames. +final class ViewCameraComponent extends Component { + ViewCameraComponent( + this.viewCamera, { + super.priority = BridgePriority.camera, + }); + + final ViewCamera viewCamera; + + @override + void update(double dt) => viewCamera.advance(dt); +} diff --git a/packages/flame_flutter3d/lib/src/debug/hitboxes3d.dart b/packages/flame_flutter3d/lib/src/debug/hitboxes3d.dart new file mode 100644 index 00000000000..24f187353ad --- /dev/null +++ b/packages/flame_flutter3d/lib/src/debug/hitboxes3d.dart @@ -0,0 +1,66 @@ +import 'dart:math' as math; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/transform/bridged3d.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show shownInFlame; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// Draws every hitbox under [root] that belongs to a bridged component into +/// [lines], on its component's plane at its component's elevation: green, +/// and red while it is colliding. +/// +/// **Flame's `debugMode` draws hitboxes where Flame thinks they are**, flat +/// on its own canvas, which under a perspective 3D camera is nowhere near +/// the craft they belong to. These are drawn in the scene, among the craft, +/// so a hit that seems to miss can be seen to miss, or not. +/// +/// A rectangle or a polygon is drawn through its corners, a circle as a +/// ring of [circleSegments] sides. A hitbox with no bridged ancestor has no +/// plane to be drawn on and is left out. One under an instance is drawn as +/// one under a node is, and one under a component in a bent space is bent +/// with it, point by point, as the component is placed. +void addHitboxes3d(DebugDraw lines, Component root, {int circleSegments = 24}) { + final from = Vector3.zero(); + final to = Vector3.zero(); + for (final hitbox in root.descendants().whereType()) { + final owner = hitbox.ancestors().whereType().firstOrNull; + if (owner == null) { + continue; + } + if (owner is HasVisibility && !shownInFlame(owner as HasVisibility)) { + continue; + } + final plane = owner.plane; + final space = owner.space; + final lift = owner.elevation; + final at = plane.constant + lift; + final colour = hitbox.isColliding ? _colliding : _clear; + void place(Vector2 p, Vector3 out) => space == null + ? plane.to3dInto(p.x, p.y, out, at: at) + : space.place(p.x, p.y, lift, out); + + final outline = switch (hitbox) { + final PolygonComponent polygon => polygon.globalVertices(), + final CircleHitbox circle => [ + for (var i = 0; i < circleSegments; i++) + circle.absoluteCenter + + Vector2( + math.cos(2.0 * math.pi * i / circleSegments), + math.sin(2.0 * math.pi * i / circleSegments), + ) * + (circle.radius * circle.absoluteScale.x.abs()), + ], + _ => const [], + }; + for (var i = 0; i < outline.length; i++) { + place(outline[i], from); + place(outline[(i + 1) % outline.length], to); + lines.addLine(from.clone(), to.clone(), colour); + } + } +} + +Vector4 get _clear => Vector4(0.3, 1.0, 0.4, 1.0); +Vector4 get _colliding => Vector4(1.0, 0.3, 0.25, 1.0); diff --git a/packages/flame_flutter3d/lib/src/ecs/actor_component.dart b/packages/flame_flutter3d/lib/src/ecs/actor_component.dart new file mode 100644 index 00000000000..c851a3e6793 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/ecs/actor_component.dart @@ -0,0 +1,193 @@ +/// [ActorComponent] bridges one flutter3d_sim [Actor] to Flame — the +/// actor's simulated body kept in step with the [SceneNode] a game draws it +/// as. +library; + +import 'package:flame/collisions.dart' show CollisionCallbacks; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/ecs/actor_system_component.dart'; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' show SceneNode; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +/// A Flame [PositionComponent] wrapping one flutter3d_sim [Actor] — the +/// same [SceneNode]/[BridgePlane] bridge [Object3dComponent] gives every +/// other bridged transform, plus the one extra hop an actor needs: its +/// body's simulated position lives on a [CharacterController], not on +/// [node]. +/// +/// **Why [direction] defaults to [SyncDirection.sceneToFlame].** An actor's +/// body is stepped by [ActorSystem.step] — run once a frame by +/// [ActorSystemComponent], never by this component — and that step is +/// scene-authoritative in exactly the sense [SyncDirection]'s own doc +/// already names it: nothing about a Flame position feeds back into it. +/// The rare game that drives an actor's body from a Flame-side animation +/// instead can still pass [SyncDirection.flameToScene] explicitly; this +/// default is only a default. +/// +/// **Why [update] copies [Actor.body]'s position onto [node] before calling +/// `super.update`.** [Object3dComponent.update] reads `node.readPosition()` +/// — it has never heard of an [Actor], and should not have to, or every +/// bridge component in this package would need to know about every other +/// one's data model. The position [ActorSystem.step] just computed lives on +/// the [CharacterController] itself, so getting it onto the Flame side +/// means getting it onto [node] first. It is the same "sync the visual +/// thing from the real body" step `apps/flutter3d_showcase`'s rigid-body +/// demo already does by hand for a crate (`mesh.setPositionFrom(_crate +/// .position)`), done here once so every actor in a bridged game gets it +/// for free instead of every game re-deriving it. +/// +/// **The component and the actor live and die together, both ways.** An +/// actor the simulation takes out — `ActorSystem.remove`, a horde burying its +/// dead — takes this component with it on the next [update]: it used to stay, +/// its node frozen where the body last stood, a monster drawn after it was +/// gone. The other way is [removesFrom]: handed the system, taking this +/// component out of the game takes the actor out of the system, where it used +/// to go on thinking, biting and blocking a corridor unseen. Left null, the +/// actor is whoever built it's to remove, which is right when the simulation +/// owns its actors and the component only draws one. +final class ActorComponent extends Object3dComponent + with CollisionCallbacks + implements StepFollower { + ActorComponent({ + required this.actor, + required super.node, + required super.scene, + required super.plane, + this.stepper, + this.removesFrom, + super.direction = SyncDirection.sceneToFlame, + super.elevation, + super.position, + super.size, + super.anchor, + super.angle, + super.scale, + super.children, + super.priority, + super.key, + }); + + /// The flutter3d_sim actor this component bridges to Flame. + final Actor actor; + + /// What steps [actor], when this should draw between its steps; see + /// `RigidBodyComponent.stepper`. Null draws it where it is. + /// + /// An [ActorSystemComponent], or the game itself when the game steps its + /// own simulation in `HasFixedStep.fixedUpdate` — any [StepClock]. + final StepClock? stepper; + + /// The system [actor] leaves when this component leaves the game, or null + /// for an actor this component only draws. + final ActorSystem? removesFrom; + + final Vector3 _before = Vector3.zero(); + final Vector3 _drawn = Vector3.zero(); + double _yawBefore = 0.0; + bool _remembered = false; + + /// Keeps where the actor's body is now, and which way it faces, as where + /// it was before the next step. Called by [stepper] before each step. + @override + void rememberPlace() { + final body = actor.body; + if (body == null) { + return; + } + _before.setFrom(body.position); + _yawBefore = actor.yaw; + _remembered = true; + } + + /// Carries the actor's body across too, still moving; see + /// `RigidBodyComponent.shiftScene`. + @override + void shiftScene(Vector3 by) { + super.shiftScene(by); + final body = actor.body; + if (body == null) { + return; + } + body.position.add(by); + body.collider + ..position.setFrom(body.position) + ..refreshBounds(); + _before.add(by); + } + + @override + void onMount() { + super.onMount(); + // Added again, it draws from where the body is, not from where it was + // when it went. + _remembered = false; + stepper?.follow(this); + } + + @override + void onRemove() { + stepper?.unfollow(this); + final system = removesFrom; + if (system != null && actor.exists) { + system.remove(actor); + } + super.onRemove(); + } + + /// Copies the actor's body and facing onto [node], then lets + /// [Object3dComponent.update] read them onto the Flame side. + /// + /// **Only when the scene is authoritative.** Flowing Flame to the scene, + /// the node is written from Flame's position straight after, and copying + /// the body there first did nothing but cost a write. + /// + /// **The facing too, not only the place.** An actor turns by its yaw, + /// radians about Y with nought looking along −Z, which is the rotation a + /// node is drawn with; without it every bridged actor slid about facing + /// the one way it was built facing. + @override + void update(double dt) { + if (!actor.exists) { + // Gone from the simulation: gone from the game. + if (!isRemoving) { + removeFromParent(); + } + return; + } + if (direction == SyncDirection.sceneToFlame) { + final body = actor.body; + // Null for an actor with no body (a turret, a director) and for one + // that has been despawned — both are "nothing to copy", not an error. + final steps = stepper; + if (body != null && steps != null && _remembered) { + Vector3.mix(_before, body.position, steps.alpha, _drawn); + placeNode(_drawn); + } else if (body != null) { + placeNode(body.position); + } + // Turned between its steps as it is moved between them: a bot's place + // glided and its facing clicked round sixty times a second. + if (actor.facing != null) { + turnNodeTo( + steps != null && _remembered + ? _between(_yawBefore, actor.yaw, steps.alpha) + : actor.yaw, + ); + } + } + super.update(dt); + } + + /// [t] of the way from angle [a] to angle [b], the short way round. + static double _between(double a, double b, double t) { + const whole = 6.283185307179586; + var turn = (b - a) % whole; + if (turn > whole / 2.0) { + turn -= whole; + } + return a + turn * t; + } +} diff --git a/packages/flame_flutter3d/lib/src/ecs/actor_system_component.dart b/packages/flame_flutter3d/lib/src/ecs/actor_system_component.dart new file mode 100644 index 00000000000..f77a0f7b3ad --- /dev/null +++ b/packages/flame_flutter3d/lib/src/ecs/actor_system_component.dart @@ -0,0 +1,171 @@ +/// [ActorSystemComponent] steps one shared flutter3d_sim [ActorSystem], +/// once a frame, wherever Flame's own game loop already is. +library; + +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/ecs/actor_component.dart'; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +/// The one place a bridged game's frame steps a shared [ActorSystem]. +/// +/// **Plain [Component], not [PositionComponent].** It draws nothing and +/// sits nowhere — each actor it steps has its own [ActorComponent] for +/// that — so it carries none of the transform a [PositionComponent] would +/// otherwise make a caller invent an answer for. +/// +/// **Why stepping lives here and not on every [ActorComponent].** +/// [ActorSystem.step]'s own doc states the protocol it is half of: +/// [ActorSystem.beginStep] must run once, immediately before it, every +/// frame — call `step` again without a fresh `beginStep` and it throws; +/// call `beginStep`/`step` more than once a frame and every actor's +/// physics runs twice that frame. A game with N actors sharing one +/// [ActorSystem] but stepping it from N different [ActorComponent]s would +/// do exactly that, and stepping the whole system N times to move a +/// world's worth of actors N times too fast is not something any one +/// actor's component can see from where it sits — only whoever owns the +/// system can. So [ActorComponent] itself never calls [ActorSystem.step] +/// or [ActorSystem.beginStep]; this is the only caller, and it calls the +/// pair exactly once per [update]. +/// +/// **In fixed steps, not in frames**, for the reason +/// `PhysicsStepComponent` gives: the frame's time is spent in whole steps of +/// [step]'s size, the `beginStep`/`step` pair once per step, and an +/// [ActorComponent] handed this component draws its actor [alpha] of the +/// way between its last two places. +/// +/// **Why [focus] and [focusBody] are closures, not values captured once.** +/// [ActorSystem.step] needs to know where the world's one focus point is +/// *this frame* — a player's own position, typically — and a value taken +/// once at construction would freeze it at wherever that was when this +/// component was built. Reading a fresh `Vector3`/`Collider?` every +/// [update] costs one call each and is the only way this component can +/// hand [ActorSystem.step] a focus that has actually moved since. +/// +/// **In a `HasFixedStep` game it steps with the game**, once in each of the +/// game's steps, and [step] is not used: see [HasFixedStep]. The system's +/// step is opened — [ActorSystem.beginStep] — at the *start* of the game's +/// step, before the game's own logic, and the actors are stepped later in it. +/// Opened just before the actors, it wiped whatever the game's logic had +/// already reported that step: a player's shot killed a monster and the death +/// was gone before anybody read it. +/// +/// **One focus or several.** [focus] and [focusBody] name the one thing +/// everything chases; [foci] names several — the players of a co-op game — +/// and each actor then goes for the one it can reach first, as +/// [ActorSystem.step] explains. Exactly one of the two. +final class ActorSystemComponent extends Component + with FixedStepUpdate + implements StepClock { + ActorSystemComponent({ + required this.system, + this.focus, + this.focusBody, + this.foci, + FixedStep? step, + super.priority = BridgePriority.actors, + }) : assert( + (focus == null) != (foci == null), + 'an actor system is stepped towards one focus or several foci', + ), + step = step ?? FixedStep(); + + /// The actor system every [ActorComponent] in this game shares. + final ActorSystem system; + + /// Where the system's one focus point is, read fresh every step. + final Vector3 Function()? focus; + + /// What the focus point belongs to, or null for a focus with no body of + /// its own — read fresh every step, for the same reason as [focus]. + final Collider? Function()? focusBody; + + /// Every focus, read fresh every step: the living players, in an order that + /// stays put, since [ActorSystem.damageToFoci] is read by it. + final List Function()? foci; + + /// How the frame's time is cut into steps: one sixtieth of a second each + /// unless given otherwise. + final FixedStep step; + + /// How far this frame is past the last step, from 0 up to 1: the game's, + /// when the game steps it. + @override + double get alpha => _game?.alpha ?? step.alpha; + + HasFixedStep? _game; + + @override + void onMount() { + super.onMount(); + _game = switch (findGame()) { + final HasFixedStep game => game, + _ => null, + }; + _game?.beforeEachStep(_open); + } + + @override + void onRemove() { + _game?.removeBeforeEachStep(_open); + _game = null; + super.onRemove(); + } + + final Set _followers = {}; + + /// [follower] is told where its body was before each step. [ActorComponent] + /// does this for itself when handed this component. + @override + void follow(StepFollower follower) => _followers.add(follower); + + /// Stops telling [follower]. + @override + void unfollow(StepFollower follower) => _followers.remove(follower); + + bool _opened = false; + + void _open() { + system.beginStep(); + _opened = true; + } + + @override + void update(double dt) { + super.update(dt); + if (_game != null) { + return; + } + final steps = step.advance(dt); + for (var i = 0; i < steps; i++) { + _open(); + fixedUpdate(step.stepSeconds); + } + } + + /// One step of the system, of [seconds]. + @override + void fixedUpdate(double seconds) { + for (final follower in _followers) { + follower.rememberPlace(); + } + // Mounted in the middle of a game's step: the step was opened without us. + if (!_opened) { + system.beginStep(); + } + _opened = false; + final several = foci; + if (several != null) { + final points = several(); + // Nobody left to chase: the step is the game's to end, not ours. + if (points.isEmpty) { + return; + } + system.step(seconds, foci: points); + } else { + system.step(seconds, focus: focus!(), focusBody: focusBody?.call()); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/ecs/instanced_actor_component.dart b/packages/flame_flutter3d/lib/src/ecs/instanced_actor_component.dart new file mode 100644 index 00000000000..e84e0af75f9 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/ecs/instanced_actor_component.dart @@ -0,0 +1,214 @@ +/// Many actors of one shape, each an instance of one batch rather than a node +/// of its own. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame_flutter3d/flame_flutter3d.dart' show ActorComponent; +import 'package:flame_flutter3d/src/ecs/actor_component.dart' + show ActorComponent; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_sim/flutter3d_sim.dart' show Actor, ActorSystem; +import 'package:vector_math/vector_math.dart' show Matrix4, Vector3; + +/// One simulated [Actor] drawn as a slot of a shared [InstancedMeshNode]. +/// +/// **What [ActorComponent] is for a horde.** Each `ActorComponent` is a node +/// and a draw, which is right for a boss and wrong for two hundred monsters +/// of three kinds: that is two hundred draws where three would do. +/// `InstancedObject3dComponent` is one draw for many, but it flows from +/// Flame to the scene, and a horde's place is decided by the simulation. This +/// takes a slot in [batch] when it is mounted, writes the actor's body and +/// facing into it every frame — [stepper]'s `alpha` of the way from where the +/// body was before the step, as `ActorComponent` does — and gives the slot +/// back when it goes. +/// +/// **It lives and dies with the actor**, both ways, as `ActorComponent` does: +/// an actor the simulation takes out takes this with it, and handed +/// [removesFrom], taking this out of the game takes the actor out of the +/// system. +/// +/// **[batch] sits at the scene's origin, unturned**, as for +/// `InstancedObject3dComponent`: the transform written is the body's place in +/// the scene as it is. +class InstancedActorComponent extends Component implements StepFollower { + InstancedActorComponent({ + required this.actor, + required this.batch, + this.stepper, + this.removesFrom, + this.lift = 0.0, + super.priority, + }); + + final Actor actor; + + /// The batch this actor takes a slot in. + final InstancedMeshNode batch; + + /// What steps [actor], for drawing it between its steps; null draws it + /// where it is. + final StepClock? stepper; + + /// The system [actor] leaves when this component leaves the game, or null + /// for an actor this only draws. + final ActorSystem? removesFrom; + + /// Metres added to the body's height before it is drawn: a mesh built with + /// its feet at its origin under a body whose position is its middle. + final double lift; + + InstanceHandle? _slot; + + /// The slot this actor is drawn through, while it is mounted. + InstanceHandle? get slot => _slot; + + final Vector3 _before = Vector3.zero(); + final Vector3 _drawn = Vector3.zero(); + double _yawBefore = 0.0; + bool _remembered = false; + final Matrix4 _transform = Matrix4.identity(); + + @override + void rememberPlace() { + final body = actor.body; + if (body == null) { + return; + } + _before.setFrom(body.position); + _yawBefore = actor.yaw; + _remembered = true; + } + + @override + void onMount() { + super.onMount(); + _remembered = false; + _slot = batch.acquire(); + stepper?.follow(this); + _write(); + } + + @override + void onRemove() { + stepper?.unfollow(this); + final slot = _slot; + _slot = null; + if (slot != null && slot.live) { + batch.release(slot); + } + final system = removesFrom; + if (system != null && actor.exists) { + system.remove(actor); + } + super.onRemove(); + } + + @override + void update(double dt) { + super.update(dt); + if (!actor.exists) { + if (!isRemoving) { + removeFromParent(); + } + return; + } + _write(); + } + + void _write() { + final slot = _slot; + final body = actor.body; + if (slot == null || body == null) { + return; + } + final steps = stepper; + final double yaw; + if (steps != null && _remembered) { + Vector3.mix(_before, body.position, steps.alpha, _drawn); + yaw = _between(_yawBefore, actor.yaw, steps.alpha); + } else { + _drawn.setFrom(body.position); + yaw = actor.yaw; + } + _transform + ..setIdentity() + ..setTranslationRaw(_drawn.x, _drawn.y + lift, _drawn.z) + ..rotateY(yaw); + slot.setTransform(_transform); + } + + static double _between(double a, double b, double t) { + const whole = 6.283185307179586; + var turn = (b - a) % whole; + if (turn > whole / 2.0) { + turn -= whole; + } + return a + turn * t; + } +} + +/// A thing the simulation keeps that is not an actor — a shot in flight — +/// drawn as a slot of a shared [InstancedMeshNode] for as long as [place] +/// says it is still there. +/// +/// [place] writes where it is this frame into the vector it is handed, and +/// answers false once it is gone, which takes this component and its slot +/// away. For a sim's own list of short-lived things the game mirrors one +/// component per item. +class InstancedPoseComponent extends Component { + InstancedPoseComponent({ + required this.batch, + required this.place, + super.priority, + }); + + final InstancedMeshNode batch; + + /// Where the thing is now; false once it is gone. + final bool Function(Vector3 at) place; + + InstanceHandle? _slot; + final Vector3 _at = Vector3.zero(); + final Matrix4 _transform = Matrix4.identity(); + + @override + void onMount() { + super.onMount(); + _slot = batch.acquire(); + _write(); + } + + @override + void onRemove() { + final slot = _slot; + _slot = null; + if (slot != null && slot.live) { + batch.release(slot); + } + super.onRemove(); + } + + @override + void update(double dt) { + super.update(dt); + if (!_write() && !isRemoving) { + removeFromParent(); + } + } + + bool _write() { + final slot = _slot; + if (slot == null) { + return false; + } + if (!place(_at)) { + return false; + } + _transform + ..setIdentity() + ..setTranslationRaw(_at.x, _at.y, _at.z); + slot.setTransform(_transform); + return true; + } +} diff --git a/packages/flame_flutter3d/lib/src/host/bridge_clock.dart b/packages/flame_flutter3d/lib/src/host/bridge_clock.dart new file mode 100644 index 00000000000..dad22150b28 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/bridge_clock.dart @@ -0,0 +1,47 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show Flutter3dFlameWidget; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/flutter3d_flame_widget.dart' + show Flutter3dFlameWidget; +import 'package:flutter3d/flutter3d.dart' show GraphicsDevice; + +/// The one place a bridged game's frame steps flutter3d's own systems, +/// riding Flame's own game loop rather than a second ticker. +/// +/// **Why not a `Ticker` of its own.** Flame's `GameWidget` already runs one, +/// synced to vsync, and `FlameGame.update(dt)` fires from it every frame. +/// A second ticker driving flutter3d's side would need its own +/// synchronization with the first to avoid the two drifting apart — the +/// exact class of bug two clocks always risk. Adding this as an ordinary +/// [Component] to the same [FlameGame] instead means there is only ever one +/// clock in a bridged game, and it is the one Flame already owns. +/// +/// [Flutter3dFlameWidget] adds one of these to the [FlameGame] it hosts and +/// calls [onTick] with every frame's own `dt`, after every other component's +/// `update` has run. +/// +/// **That ordering is a priority, not an accident of when [add] was +/// called.** Flame breaks a tie between equal priorities by insertion order, +/// and [Flutter3dFlameWidget] adds this component from its first `build` — +/// which, when it opens its own [GraphicsDevice], happens *before* +/// `buildScene` returns and the host's own components exist to be tied +/// with. A [BridgeClock] left at the default priority would then run +/// *first*, not last, on that path. [priority] is set far past anything a +/// caller's own game would plausibly use instead, so the order holds +/// regardless of which of the two ever gets added first. +final class BridgeClock extends Component { + BridgeClock({required this.onTick}) : super(priority: BridgePriority.clock); + + /// Called every time Flame updates this component, with Flame's own `dt` + /// in seconds, not a second measurement of it: once a frame from the game + /// loop, and with `dt == 0` when `GameWidget` updates the game from its own + /// layout, which it does when it is rebuilt (its first frame, a resize). + final void Function(double dt) onTick; + + @override + void update(double dt) { + super.update(dt); + onTick(dt); + } +} diff --git a/packages/flame_flutter3d/lib/src/host/bridge_priority.dart b/packages/flame_flutter3d/lib/src/host/bridge_priority.dart new file mode 100644 index 00000000000..122fcdcf00f --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/bridge_priority.dart @@ -0,0 +1,60 @@ +/// Where in a Flame frame each part of the bridge updates, by name. +/// +/// **What every bridged game worked out for itself.** Flame updates a +/// game's children by ascending priority, and the bridge's parts have an +/// order that matters: the phone's stick is read before anything moves, the +/// simulation steps before whatever reads it, the camera follows once the +/// craft have moved, the sound mixes after all of it, and the clock that +/// draws the 3D frame comes last. Each game that used the bridge picked its +/// own numbers for that (the arcade -120 and -110, the example -100) and +/// each component's doc said "give it a priority below the readers". These +/// are those numbers, and the bridge's components take them by default. +/// +/// A game's own components sit at Flame's default of 0, between the +/// simulation and the camera, which is where a player's craft wants to be. +/// +/// **After Flame's own camera, what reads it.** Flame gives its +/// `CameraComponent` the highest 32-bit priority, so that it follows its +/// target after everything has moved. The clock, the sound and the input's +/// end were placed at 2^20 and so ran before it, and a 3D camera synced +/// from a viewfinder that `camera.follow()` moves trailed it by a frame. +/// They are past it now; Dart's integers, and the web's, go far enough. +abstract final class BridgePriority { + /// A touch stick's deflection read into the input state: before anything + /// that reads input. + static const int input = -(1 << 30); + + /// `KinematicBodyComponent`: a lift moves before whoever stands on it + /// steps. + static const int kinematic = -1200; + + /// `ActorSystemComponent`: the actors step before the bodies they push. + static const int actors = -1100; + + /// `PhysicsStepComponent`: the solver, before anything reads a body. + static const int physics = -1000; + + /// `ChaseCameraComponent`, and a `CameraSyncComponent` that writes Flame's + /// viewfinder from the 3D camera: after the craft they follow have moved + /// this frame, before Flame's camera reads its viewfinder. + static const int camera = 1000; + + /// Flame's own `CameraComponent`, which follows its target once the + /// world has moved. Not the bridge's to set; named to order against. + static const int flameCamera = 0x7fffffff; + + /// A `CameraSyncComponent` that writes the 3D camera from Flame's + /// viewfinder: after Flame's camera has moved it this frame. + static const int afterFlameCamera = flameCamera + 1; + + /// A game's sound mixing, after everything that makes one has spoken and + /// every camera its ears ride on has moved. + static const int audio = (1 << 32) - 2; + + /// `FlameInputBridge.stepEnd`: the input step closed once everything that + /// reads it this frame has, just before the frame is drawn. + static const int inputEnd = (1 << 32) - 1; + + /// `BridgeClock`, which draws the 3D frame: last of all. + static const int clock = 1 << 32; +} diff --git a/packages/flame_flutter3d/lib/src/host/flutter3d_flame_widget.dart b/packages/flame_flutter3d/lib/src/host/flutter3d_flame_widget.dart new file mode 100644 index 00000000000..395341ec35e --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/flutter3d_flame_widget.dart @@ -0,0 +1,529 @@ +import 'package:flame/game.dart' + show FlameGame, GameWidget, OverlayWidgetBuilder; +import 'package:flame_flutter3d/src/host/bridge_clock.dart'; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/host/transparent_flame_game.dart'; +import 'package:flutter/foundation.dart' show setEquals; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:vector_math/vector_math.dart' show Vector4; + +/// A 3D flutter3d layer and a 2D Flame layer, composited in one `Stack`, one +/// frame each. +/// +/// **Both engines render their own layer.** [SceneSurface] draws the 3D +/// scene [buildScene] returns; Flame's own [GameWidget] draws [game]. Neither +/// engine's renderer is reimplemented, and neither drives the other's +/// drawing — they sit in one `Stack`, [game]'s [GameWidget] on top, the same +/// arrangement `apps/flutter3d_demo_platformer` already uses for its own HUD +/// and input layer over a bare `SceneSurface`: on the web the 3D surface is a +/// platform view that swallows pointer events, so whatever needs raw input — +/// here, Flame itself — has to sit above it in the tree. +/// +/// **[game] must not paint an opaque background.** `GameWidget` paints +/// `game.backgroundColor()` as a `DecoratedBox` behind its own canvas, and +/// `Game.backgroundColor()` defaults to opaque black — which, sitting on top +/// of [SceneSurface] the way this widget arranges the two, draws a solid +/// black rectangle over the whole 3D layer every frame. Extend +/// [TransparentFlameGame] instead of [FlameGame], or override +/// `backgroundColor()` the same way it does. +/// +/// **A game that owns its world needs nothing else.** Give [game] the +/// [HasFlutter3d] mixin and pass it alone: its [HasFlutter3d.camera3d] is +/// the camera, [HasFlutter3d.open3d] opens its scene on this widget's +/// device, its renderer is handed to [HasFlutter3d.attachRenderer], and its +/// [HasFlutter3d.renderSettings] and [HasFlutter3d.clearColor] draw the +/// frame. [camera], [buildScene], [settings], [clearColor] and +/// [onRendererReady] are for a game without it, and override it where given. +/// +/// **One clock.** A [BridgeClock] is added to [game] once it loads, and every +/// Flame frame — after every other component in [game] has updated — calls +/// [onTick] with that frame's own `dt`, then triggers a Flutter rebuild so +/// [SceneSurface] renders the 3D frame in step. Nothing here starts a second +/// ticker; see [BridgeClock] for why that matters. +/// +/// **A new game is a new host.** A rebuild that hands in a different [game] +/// gets a fresh device, scene and clock for it, as if the widget had just +/// appeared; the old game keeps its world, or lets its device go, as it +/// would had the widget gone. Before, the new game was drawn over the old +/// one's scene and never had its own opened. +class Flutter3dFlameWidget extends StatelessWidget { + const Flutter3dFlameWidget({ + required this.game, + super.key, + this.camera, + this.buildScene, + this.existing, + this.onRendererReady, + this.onTick, + this.clearColor, + this.settings, + this.width = 1280, + this.height = 720, + this.overlayBuilderMap, + this.initialActiveOverlays, + this.focusNode, + this.autofocus = true, + }) : assert( + game is HasFlutter3d || (camera != null && buildScene != null), + 'A game without HasFlutter3d needs a camera and a buildScene.', + ); + + /// The Flame game whose [GameWidget] draws the 2D layer. Constructed by + /// the caller — this widget only adds one [BridgeClock] to it, once. + final FlameGame game; + + /// The camera the 3D layer renders through. Added to the built [Scene] + /// automatically if [buildScene] did not already add it. Null for a + /// [HasFlutter3d] game, whose [HasFlutter3d.camera3d] it is. + final CameraNode? camera; + + /// Builds the 3D scene once a [GraphicsDevice] is open. Called exactly + /// once, the same contract `flutter3d_app`'s own examples use. Null for a + /// [HasFlutter3d] game, which builds its own in [HasFlutter3d.onOpen3d]. + final Scene Function(GraphicsDevice device)? buildScene; + + /// A device and renderer opened by the caller, reused instead of this + /// widget opening its own. A host that already has one — a page inside a + /// larger application, say, where `DemoContext` hands one out per page — + /// opening a second `GraphicsDevice` just to show a bridged demo would be + /// two GPU contexts open for one picture. `openDevice` runs only when this + /// is null. + final ({GraphicsDevice device, Renderer renderer})? existing; + + /// Called once with the [Renderer] the 3D layer draws with, as soon as + /// there is one: after [buildScene], whether this widget opened the device + /// or was handed [existing]. + /// + /// **For what a game has to ask the renderer itself**: letting go of a + /// mesh it streamed in (`Renderer.releaseMeshAfterFrame`), adding a + /// contributor that draws particles. Without it a bridged game saw the + /// device in [buildScene] and never the renderer, which this widget made + /// and kept. + final void Function(Renderer renderer)? onRendererReady; + + /// Called every time Flame updates [game], after its own components have, + /// with that update's `dt`: the seam a physics step, an actor system step, + /// or a camera sync controller advances from. + /// + /// **Once a frame, and occasionally with a `dt` of zero.** `GameWidget` + /// calls `update(0)` from its own layout whenever it is rebuilt: on its + /// first frame, and when its size changes. This widget no longer rebuilds + /// it every frame, but a step that divides by `dt` should still ignore a + /// zero. + final void Function(double dt)? onTick; + + /// The 3D layer's clear color, behind whatever [buildScene] draws. + final Vector4? clearColor; + + /// What the 3D layer's frame is drawn with. Re-read every frame, after + /// [onTick] — the same contract `SceneSurface.settings` already has. + final RenderSettings Function()? settings; + + /// Flame's overlays: Flutter widgets over the game, shown and hidden by + /// name through `game.overlays`. Handed to the `GameWidget` as they are. + /// + /// **What a bridged game had to build a second `Stack` for.** A pause + /// menu or a name entry over the 3D layer is what `GameWidget` already + /// does with these; the host did not pass them on. + final Map>? overlayBuilderMap; + + /// The overlays shown from the start. + final List? initialActiveOverlays; + + /// The focus the game's keyboard listens through, for a host that moves + /// focus between the game and its own widgets. + final FocusNode? focusNode; + + /// Whether the game takes the keyboard focus when it appears. + final bool autofocus; + + /// The [GraphicsDevice]'s own backing size — not this widget's size on + /// screen, which `SceneSurface` already resizes the render target to + /// independently of this. + final int width; + final int height; + + @override + Widget build(BuildContext context) => + _Flutter3dFlameHost(key: ObjectKey(game), config: this); +} + +class _Flutter3dFlameHost extends StatefulWidget { + const _Flutter3dFlameHost({required this.config, super.key}); + + final Flutter3dFlameWidget config; + + @override + State<_Flutter3dFlameHost> createState() => _Flutter3dFlameHostState(); +} + +class _Flutter3dFlameHostState extends State<_Flutter3dFlameHost> { + Flutter3dFlameWidget get _config => widget.config; + + /// The game, when it owns its world. + HasFlutter3d? get _owner => switch (_config.game) { + final HasFlutter3d owner => owner, + _ => null, + }; + + CameraNode get _camera => _config.camera ?? _owner!.camera3d; + + late RenderView _view = _viewFor(); + + RenderView _viewFor() => RenderView( + camera: _camera, + clearColor: + _config.clearColor ?? + _owner?.clearColor ?? + Vector4(0.05, 0.05, 0.07, 1.0), + ); + + /// The camera this state put in the scene, to take out again when a + /// rebuild hands in another; a scene kept every camera it was ever given. + CameraNode? _addedCamera; + + /// **A new camera or a new clear colour is used.** Both went into the view + /// once, when this state was made, and a rebuild that handed in a + /// different camera, or a sky for the next level, changed nothing on + /// screen. New overlays or a new focus make a new `GameWidget`. + @override + void didUpdateWidget(_Flutter3dFlameHost old) { + super.didUpdateWidget(old); + final was = old.config; + if (!identical(was.camera, _config.camera) || + was.clearColor != _config.clearColor) { + final scene = _ready?.scene; + final camera = _camera; + final added = _addedCamera; + if (added != null && !identical(added, camera)) { + added.removeFromParent(); + _addedCamera = null; + } + if (scene != null && !scene.cameras.contains(camera)) { + scene.add(camera); + _addedCamera = camera; + } + _view = _viewFor(); + } + // **Same names, new builders: the overlays rebuild, `GameWidget` stays.** + // A map written inline in a parent's `build` is a new map of new closures + // every time the parent rebuilds, and replacing `GameWidget` for it made + // Flame update the game again from its layout on each of those rebuilds. + // The overlays read the builders through [_overlays], so fresh closures + // reach the screen without a new `GameWidget`. + if (!identical(was.overlayBuilderMap, _config.overlayBuilderMap)) { + _overlayBuilders.value++; + } + if (!setEquals( + was.overlayBuilderMap?.keys.toSet(), + _config.overlayBuilderMap?.keys.toSet(), + ) || + !identical(was.initialActiveOverlays, _config.initialActiveOverlays) || + !identical(was.focusNode, _config.focusNode) || + was.autofocus != _config.autofocus) { + _gameWidget = null; + } + } + + /// The scene on [device]: built by [Flutter3dFlameWidget.buildScene] when + /// given, opened by the game when it owns its world. + Scene _sceneOn(GraphicsDevice device) { + final build = _config.buildScene; + if (build != null) { + final scene = build(device); + if (scene.cameras.isEmpty) { + scene.add(_camera); + _addedCamera = _camera; + } + final owner = _owner; + if (owner != null && !owner.has3d) { + owner.open3d(device, scene: scene); + } + return scene; + } + final owner = _owner!; + if (!owner.has3d) { + owner.open3d(device); + } + return owner.scene; + } + + void _rendererReady(Renderer renderer) { + _owner?.attachRenderer(renderer); + _config.onRendererReady?.call(renderer); + } + + RenderSettings Function() get _settings => + _config.settings ?? + _owner?.renderSettings ?? + () => const RenderSettings(); + + ({Renderer renderer, Scene scene})? _ready; + Object? _error; + + /// The clock added to [Flutter3dFlameWidget.game], once. + BridgeClock? _clock; + + /// Closes the device and renderer this state opened, when they are its to + /// close: not when they came in through [Flutter3dFlameWidget.existing], + /// which are the caller's, nor when the game took them with + /// [HasFlutter3d.closeWith] to keep its world on. + void Function()? _release; + + /// Bumped once a Flame update to redraw the 3D layer, and nothing else. + /// + /// **Only the 3D layer is rebuilt each frame.** A `setState` here used to + /// rebuild the whole `Stack`, `GameWidget` with it, and `GameWidget` calls + /// `game.update(0)` from its own layout whenever it is rebuilt, so every + /// frame the game was updated twice, [BridgeClock] fired twice and + /// `onTick` saw a second call with a `dt` of zero. + final ValueNotifier _frames = ValueNotifier(0); + + /// The `GameWidget`, made once per game and handed back unchanged, so a + /// rebuild of this widget from above (a HUD beside it calling `setState`, + /// say) does not rebuild Flame's own widget either. + GameWidget? _gameWidget; + + /// Bumped when the parent hands in new overlay builders under the same + /// names; every overlay [_overlays] builds listens to it. + final ValueNotifier _overlayBuilders = ValueNotifier(0); + + /// The map `GameWidget` is given: one entry per name in + /// [Flutter3dFlameWidget.overlayBuilderMap], each building through + /// whatever builder the config holds *now*. + Map? get _overlays => + switch (_config.overlayBuilderMap) { + null => null, + final map => { + for (final name in map.keys) + name: (BuildContext context, FlameGame game) => + ValueListenableBuilder( + valueListenable: _overlayBuilders, + builder: (BuildContext context, int _, Widget? _) => + _config.overlayBuilderMap![name]!(context, game), + ), + }, + }; + + /// [_redraw], torn off once: two tear-offs of one method are equal but + /// never identical, and [dispose] asks whether the game still holds this + /// one. + late final void Function() _redrawer = _redraw; + + @override + void initState() { + super.initState(); + assert(() { + // Not an assert that throws: a game that means to cover the 3D layer + // is allowed to, and one that does not is told why the screen is one + // colour. + if (_config.game.backgroundColor().a > 0.0) { + debugPrint( + 'Flutter3dFlameWidget: ${_config.game.runtimeType} paints an opaque ' + 'background over the 3D layer, which will not be seen. Mix in ' + 'HasFlutter3d, extend TransparentFlameGame, or return a clear ' + 'colour from backgroundColor().', + ); + } + return true; + }()); + final owner = _owner; + final kept = owner != null && owner.has3d ? owner.renderer : null; + final existing = _config.existing; + if (owner != null && kept != null) { + // Shown again: the world it kept is drawn as it is, on the device it + // was built on. + assert( + existing == null || identical(existing.device, owner.device), + "This game's world is open on another device; close3d() it first.", + ); + _ready = (renderer: kept, scene: owner.scene); + _config.onRendererReady?.call(kept); + } else if (existing != null) { + // Already open: build the scene synchronously rather than through the + // async `openDevice` path nothing here needs a second time. + try { + final scene = _sceneOn(existing.device); + _ready = (renderer: existing.renderer, scene: scene); + _rendererReady(existing.renderer); + } on Object catch (error) { + _error = error; + } + } else { + _open(); + } + } + + /// **A failure lets go of the device.** A scene or a renderer that threw + /// left the device it had opened open, with nothing holding it. + Future _open() async { + GraphicsDevice? device; + Renderer? renderer; + try { + final opened = device = await openDevice( + width: _config.width, + height: _config.height, + ); + if (!mounted) { + return opened.dispose(); + } + final scene = _sceneOn(opened); + final made = renderer = Renderer.create(device: opened); + void release() { + made.dispose(); + opened.dispose(); + } + + final owner = _owner; + if (owner != null) { + owner.closeWith(release); + } else { + _release = release; + } + setState(() => _ready = (renderer: made, scene: scene)); + _rendererReady(made); + } on Object catch (error) { + if (_ready == null) { + final owner = _owner; + if (owner != null && owner.has3d && identical(owner.device, device)) { + owner.close3d(); + } + renderer?.dispose(); + device?.dispose(); + } + if (mounted) { + setState(() => _error = error); + } + } + } + + /// **Deferred to a post-frame callback, and still in step.** A rebuild + /// asked for while a build or a layout is under way throws "called during + /// build", so it is asked for once this frame is done, and only of the 3D + /// layer ([_frames]). That does + /// not put the 3D layer a frame behind, though this comment used to say it + /// did. The callback only marks this state dirty; the next frame runs its + /// transient callbacks first, and Flame's game loop is a `Ticker` among + /// them, so [BridgeClock.update] has already moved the game to that frame + /// when the build reaches [SceneSurface], which renders from its own + /// `LayoutBuilder`. Both layers paint the same update while the ticker + /// runs. A game stepped by hand while paused (`stepEngine`) updates + /// outside a frame, and there the 3D layer does follow a frame later. + void _onFlameTick(double dt) { + if (!mounted) { + return; + } + _config.onTick?.call(dt); + _redraw(); + } + + /// Asks for the 3D layer to be drawn once this frame is done: from a tick, + /// or from [HasFlutter3d.redraw3d] while the game is paused and nothing + /// else asks for a frame. + void _redraw() { + if (!mounted) { + return; + } + WidgetsBinding.instance + ..addPostFrameCallback((Duration _) { + if (mounted) { + _frames.value++; + } + }) + ..scheduleFrame(); + } + + /// **Releases what it opened.** A device this state opened is closed with + /// it, renderer first, unless the game took it to keep its world on; one + /// passed in through [Flutter3dFlameWidget.existing] is left to its owner. + /// The clock is taken off the game too, which may outlive this widget: a + /// game kept across a route change, say, would otherwise go on calling + /// back into a disposed state. + @override + void dispose() { + _clock?.removeFromParent(); + final owner = _owner; + if (owner != null && identical(owner.redrawer3d, _redrawer)) { + owner.redrawer3d = null; + } + _frames.dispose(); + _overlayBuilders.dispose(); + _release?.call(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Added once, not once per build: `GameWidget` may rebuild this state + // without the game changing. + // + // **And only once there is a game on screen to tick for.** Added from + // the first build, a host still opening its device, or one that failed + // to, put a second clock into a game another host was already showing — + // during a route transition, say — and every `update` then called + // [Flutter3dFlameWidget.onTick] twice, stepping a simulation there twice + // a frame. Flame lets one `GameWidget` attach a game at a time, so a host + // that is ready is the only one showing it. + if (_clock == null && _error == null && _ready != null) { + final clock = _clock = BridgeClock(onTick: _onFlameTick); + _config.game.add(clock); + _owner?.redrawer3d = _redrawer; + } + + return switch ((_error, _ready)) { + (final Object error, _) => DidNotStart( + error, + background: const Color(0xFF14161A), + foreground: const Color(0xFFFF8A80), + ), + (_, null) => const ColoredBox( + color: Color(0xFF14161A), + child: Center(child: CircularProgressIndicator()), + ), + (_, (:final renderer, :final scene)?) => Stack( + fit: StackFit.expand, + children: [ + ValueListenableBuilder( + valueListenable: _frames, + builder: (BuildContext context, int frame, Widget? child) => + SceneSurface( + renderer: renderer, + // The game's scene as it is now, not the one it opened + // with: a game that moved to its next level with + // `replaceScene3d` is drawn there. + scene: _owner?.has3d ?? false ? _owner!.scene : scene, + view: _view, + moreViews: _owner?.moreViews3d ?? const [], + settings: _settings, + // The game's part of the canvas, read each frame: a split + // screen opened or closed mid-game. + onBeforeFrame: () { + final owner = _owner; + if (owner != null) { + _view.viewportFraction = owner.viewport3d; + } + }, + presentFrame: presentFrame, + ), + ), + _gameWidget ??= GameWidget( + game: _config.game, + overlayBuilderMap: _overlays, + initialActiveOverlays: _config.initialActiveOverlays, + focusNode: _config.focusNode, + autofocus: _config.autofocus, + // A world that threw while it was built says so where the game + // would be, as a device that would not open does. + errorBuilder: (BuildContext context, Object error) => DidNotStart( + error, + background: const Color(0xFF14161A), + foreground: const Color(0xFFFF8A80), + ), + ), + ], + ), + }; + } +} diff --git a/packages/flame_flutter3d/lib/src/host/has_fixed_step.dart b/packages/flame_flutter3d/lib/src/host/has_fixed_step.dart new file mode 100644 index 00000000000..cefc6fc3a90 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/has_fixed_step.dart @@ -0,0 +1,191 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show FixedStep; + +/// A component whose game logic runs in the game's fixed steps rather than +/// in its frames. See [HasFixedStep]. +mixin FixedStepUpdate on Component { + /// Moves this component on by one step of [step] seconds. + void fixedUpdate(double step); + + /// Coming, going and being reordered are what change the game's list of + /// who steps, and each says so: the game walks its tree only then. + @override + void onMount() { + super.onMount(); + _changedStepping(this); + } + + @override + void onRemove() { + _changedStepping(this); + super.onRemove(); + } + + @override + set priority(int value) { + super.priority = value; + _changedStepping(this); + } +} + +void _changedStepping(Component component) { + final game = component.findGame(); + if (game is HasFixedStep) { + game._stepping = null; + } +} + +/// A Flame game whose logic runs in fixed steps: the same second of play +/// comes out the same at any frame rate. +/// +/// **Flame's `update` is a frame, and a frame is whatever it took.** A jet +/// flown by `speed * dt` travels the same distance at any frame rate only +/// until something is decided along the way: a turn read from input, a +/// fuel tank emptied, a collision caught one frame and missed the next. A +/// replay recorded at 60 frames a second and played at 144 came out +/// differently, and so did a run on a machine that stalled. The physics and +/// the actors already step in fixed steps; this is the same for the game's +/// own logic. +/// +/// Each frame, the time is spent in whole steps of [fixedStep]'s size, at +/// most its `maxStepsPerFrame` after a stall. Each step calls +/// [fixedUpdate] on the game and then on every [FixedStepUpdate] component +/// in it, in tree order. Flame's own `update` still runs once a frame after +/// them, for what should follow the screen rather than the simulation: a +/// camera, an animation, a sound. [alpha] is how far the frame is past the +/// last step. +/// +/// **Input is closed after every step, not every frame.** A press is seen by +/// exactly one step: `FlameInputBridge.stepEnd` closes the input step from +/// [afterEachStep]. Closed once a frame, a frame of three steps showed a +/// jump's press to all three, and a frame of none closed it unseen. +/// +/// **One clock for everything that steps.** `PhysicsStepComponent` and +/// `ActorSystemComponent` in a game with this step in its steps, in tree +/// order, rather than counting their own: the runner and the crates it +/// pushes move in turn, step by step, and [alpha] is the one fraction every +/// drawing between two steps uses. +/// +/// Flame's collision detection still runs once a frame. +/// +/// Generic over the game's world, as `HasFlutter3d` is, so a game whose +/// world has a type of its own can step too. +/// +/// **A [StepClock].** A game that steps its own simulation in [fixedUpdate] — +/// a genre's `step`, moving its bodies and actors itself — hands itself to +/// the components that draw them, and they are told to keep their places +/// before each step, ahead of the game's own logic, then drawn [alpha] of the +/// way on. +mixin HasFixedStep on FlameGame implements StepClock { + /// How the frame's time is cut: a sixtieth of a second unless replaced. + FixedStep fixedStep = FixedStep(); + + int _steps = 0; + + /// How many steps this frame ran. + int get stepsThisFrame => _steps; + + /// How far this frame is past the last step, from 0 up to 1. + @override + double get alpha => fixedStep.alpha; + + final Set _followers = {}; + + @override + void follow(StepFollower follower) => _followers.add(follower); + + @override + void unfollow(StepFollower follower) => _followers.remove(follower); + + final List _stepStarts = []; + + /// Calls [start] at the start of every step, before the game's own + /// [fixedUpdate]: where a step's reports are forgotten, so that what the + /// game does in its logic is still there to be read after the step. + /// + /// `ActorSystemComponent` opens its system's step here. It used to open it + /// just before stepping the actors, after the game's own logic had run: a + /// shot fired there killed a monster, and the death was wiped before + /// anything could read it. + void beforeEachStep(void Function() start) => _stepStarts.add(start); + + /// Stops calling [start]. + void removeBeforeEachStep(void Function() start) => _stepStarts.remove(start); + + /// The game's own logic for one step of [step] seconds. + void fixedUpdate(double step) {} + + final List _stepEnds = []; + + /// Calls [end] after every step, once everything in it has run: where the + /// input step is closed. + void afterEachStep(void Function() end) => _stepEnds.add(end); + + /// Stops calling [end]. + void removeAfterEachStep(void Function() end) => _stepEnds.remove(end); + + final List _frameStarts = []; + + /// Calls [start] once a frame, before its steps: where what the steps + /// read is gathered, a touch stick's deflection say. + /// + /// **The steps run before any component updates.** A stick read in its + /// own component's update, the way a frame-by-frame game reads it, reached + /// the steps a frame after it was read, two after the finger moved. + void beforeSteps(void Function() start) => _frameStarts.add(start); + + /// Stops calling [start]. + void removeBeforeSteps(void Function() start) => _frameStarts.remove(start); + + /// The components that step, in tree order: walked out of the tree only + /// when one of them came, went or moved, and kept until then. + /// + /// **Not once a frame.** Every frame walked every component in the game to + /// find the handful that step, which in a game with a horde is hundreds of + /// components and a list, sixty times a second, for an answer that had not + /// changed. + List? _stepping; + + /// How long the frame being stepped is, in seconds: set before [beforeSteps] + /// runs, so what is read there — a stick's turn rate — is read against this + /// frame's time rather than the last one's. + double get frameSeconds => _frameSeconds; + double _frameSeconds = 0.0; + + /// A component added in a step is mounted with the frame, and joins the + /// steps after it. + @override + void update(double dt) { + _frameSeconds = dt; + for (final start in List.of(_frameStarts)) { + start(); + } + _steps = fixedStep.advance(dt); + if (_steps > 0) { + final stepping = _stepping ??= descendants() + .whereType() + .toList(growable: false); + for (var i = 0; i < _steps; i++) { + final step = fixedStep.stepSeconds; + for (final follower in List.of(_followers)) { + follower.rememberPlace(); + } + for (final start in List.of(_stepStarts)) { + start(); + } + fixedUpdate(step); + for (final component in stepping) { + if (component.isMounted && !component.isRemoving) { + component.fixedUpdate(step); + } + } + for (final end in List.of(_stepEnds)) { + end(); + } + } + } + super.update(dt); + } +} diff --git a/packages/flame_flutter3d/lib/src/host/has_flutter3d.dart b/packages/flame_flutter3d/lib/src/host/has_flutter3d.dart new file mode 100644 index 00000000000..29fe27968e9 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/has_flutter3d.dart @@ -0,0 +1,277 @@ +import 'package:flame/components.dart' show World; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show TransparentFlameGame; +import 'package:flame_flutter3d/src/debug/hitboxes3d.dart'; +import 'package:flame_flutter3d/src/host/transparent_flame_game.dart' + show TransparentFlameGame; +import 'package:flame_flutter3d/src/transform/projector.dart'; +import 'package:flutter/painting.dart' show Color; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A [FlameGame] that owns its 3D world: the scene, the camera it is seen +/// through, the renderer that draws it and the projector between the two +/// layers, all reachable from inside the game. +/// +/// **What a bridged game had to be told from outside.** Without this, the +/// device and the scene arrived in a `buildScene` callback written in the +/// app's `main.dart`, the renderer in a second callback, and everything the +/// game needed of them (a projector for a score over a target, a renderer to +/// let a mesh go through, a chase camera to shake) was handed back to it by +/// hand, in an order the app had to get right. River Sortie's `main.dart` +/// was mostly that wiring. With this mixin the game builds its own world in +/// [onOpen3d], and `Flutter3dFlameWidget(game: game)` needs nothing else. +/// +/// **The background is transparent**, as [TransparentFlameGame]'s is: the +/// 3D layer is under Flame's, and an opaque background hides it. +/// +/// ## When the world is there +/// +/// [open3d] is what opens it: `Flutter3dFlameWidget` calls it once its +/// device is open, which is before Flame loads the game, so [scene] and +/// [device] are there from [onLoad] on. A test with no widget calls it +/// itself, with a software device, before or after loading the game; either +/// way [onOpen3d] runs once, when the game has loaded and the world exists +/// to be built on. +/// +/// ## When it goes +/// +/// **The world lives as long as the game, not its widget.** Flame keeps a +/// game's components when its `GameWidget` goes, so the same game can be +/// shown again, on a tab that comes back or behind an `if`. The 3D world +/// does the same: the device the widget opened for it is left open, and a +/// widget showing the game again draws the world it already has. Before, +/// the widget closed the device under a world still built on it, and the +/// game came back with meshes on a closed device and no particles. +/// [close3d] lets it go, and [dispose] calls it. +/// +/// **Any world.** Generic over the game's world, so a `Forge2DGame`, whose +/// world is a `Forge2DWorld`, or any game with a world of its own type, can +/// have it; on `FlameGame` alone it could be mixed into a game of the plain +/// `World` and nothing else. +mixin HasFlutter3d on FlameGame { + /// The camera the 3D layer is drawn through. Made by [createCamera3d] the + /// first time it is read. + late final CameraNode camera3d = createCamera3d(); + + /// Makes [camera3d]. Override to choose the lens. + CameraNode createCamera3d() => CameraNode(name: 'camera 3d'); + + /// Between [camera3d] and Flame's screen, over this game's own [size] and + /// the part of it [viewport3d] gives the camera. + late final BridgeProjector projector = BridgeProjector( + camera: camera3d, + viewSize: () => size, + viewport: () => viewport3d, + ); + + /// The part of the canvas [camera3d] is drawn into: all of it unless a + /// split screen gives it a half. Read every frame. + ViewportRect viewport3d = const ViewportRect(0.0, 0.0, 1.0, 1.0); + + /// Views drawn after [camera3d]'s, into the same frame: the second + /// player's half of a split screen, a rear-view mirror. Each has its own + /// camera, added to [scene] by the game, and its own + /// `RenderView.viewportFraction`; a `BridgeProjector` given that part is + /// its projector. + final List moreViews3d = []; + + /// Behind everything the 3D layer draws. The same vector every frame, so + /// changing its components changes the sky on the next one. + final Vector4 clearColor = Vector4(0.05, 0.05, 0.07, 1.0); + + /// The fog the 3D layer is drawn through: what an `AtmosphereComponent` + /// in the game writes as its day turns. The rest of the air, the sky and + /// the light, lives in the scene; the fog is a setting of the frame. + FogSettings fog3d = const FogSettings(); + + /// What each 3D frame is drawn with, read before every frame: [fog3d], + /// unless overridden. An override that wants the day's fog passes + /// `fog: fog3d`. + RenderSettings renderSettings() => RenderSettings(fog: fog3d); + + GraphicsDevice? _device; + Scene? _scene; + Renderer? _renderer; + bool _opened = false; + bool _loaded = false; + bool _rendererUsed = false; + void Function()? _release; + + /// Whether [open3d] has run: whether there is a [scene] to build on. + bool get has3d => _scene != null; + + /// The device the 3D layer is open on. Throws before [open3d]. + GraphicsDevice get device => + _device ?? (throw StateError('The 3D layer is not open yet.')); + + /// The scene the 3D layer draws, with [camera3d] in it. Throws before + /// [open3d]. + Scene get scene => + _scene ?? (throw StateError('The 3D layer is not open yet.')); + + /// The renderer drawing the 3D layer, once there is one; null in a test + /// that renders no frames. + Renderer? get renderer => _renderer; + + /// Opens the 3D world on [device]: [scene], or a new one, with [camera3d] + /// added to it. Then [onOpen3d], once the game is loaded as well. + void open3d(GraphicsDevice device, {Scene? scene}) { + if (_scene != null) { + throw StateError('The 3D layer is already open.'); + } + final opened = scene ?? Scene(); + if (!opened.cameras.contains(camera3d)) { + opened.add(camera3d); + } + _device = device; + _scene = opened; + _openWhenReady(); + } + + /// Draws [next] from now on in place of [scene], on the same device and + /// through the same renderer, with [camera3d] moved across to it. + /// + /// **A level is a scene.** The engine's level loader builds one per level + /// — the brushes batched, the lights bound, the lightmap baked into it — + /// and a game with levels moves from one to the next. [open3d] opens the + /// layer once and refuses a second call, and [close3d] closes the device + /// the widget handed over with it; neither is a change of level. This is. + /// + /// What was in the old scene stays there: the game lets go of it — the + /// level's own `dispose` — once it is no longer drawn. [moreViews3d] are the + /// game's to move, since their cameras are its own. + void replaceScene3d(Scene next) { + final was = _scene; + if (was == null) { + throw StateError('The 3D layer is not open yet.'); + } + if (identical(was, next)) { + return; + } + camera3d.removeFromParent(); + if (!next.cameras.contains(camera3d)) { + next.add(camera3d); + } + _scene = next; + } + + /// Hands over the renderer the 3D layer draws with. Called by + /// `Flutter3dFlameWidget`, and by a test that renders frames; the game's + /// own use of it goes in [onRenderer3d]. + void attachRenderer(Renderer renderer) { + _renderer = renderer; + if (_debugHitboxes3d) { + _applyDebugHitboxes(); + } + _openWhenReady(); + } + + /// Draws the 3D layer once more without an update. A running game is + /// drawn every frame; a paused one is not, and a pause menu that changes + /// [clearColor] or [renderSettings], or turns [camera3d] round a showroom, + /// calls this to have it seen. + void redraw3d() => redrawer3d?.call(); + + /// What [redraw3d] calls: set by the widget showing the game. + void Function()? redrawer3d; + + /// Makes [release] this game's to call when the 3D layer closes: how + /// `Flutter3dFlameWidget` hands over a device it opened for the game, so + /// the device outlives the widget and goes with the world built on it. + // A method rather than a setter: it hands over ownership, which a setter + // would hide. + // ignore: use_setters_to_change_properties + void closeWith(void Function() release) => _release = release; + + /// Closes the 3D layer: [onClose3d], then the renderer and the device if + /// they were handed over with [closeWith]. A widget showing the game after + /// this opens it afresh, and [onOpen3d] and [onRenderer3d] run again. + void close3d() { + if (_scene == null) { + return; + } + onClose3d(); + final release = _release; + camera3d.removeFromParent(); + _release = null; + _device = null; + _scene = null; + _renderer = null; + _opened = false; + _rendererUsed = false; + release?.call(); + } + + /// Lets go of what [onOpen3d] built, before the device it is on closes. + /// A game that is shown again after [close3d] builds its world a second + /// time, and one that added components there removes them here. + void onClose3d() {} + + /// Closes the 3D layer before Flame's own clean-up: the components going + /// then find no renderer to hand their meshes to, and let the device's + /// closing take them. + @override + void dispose() { + close3d(); + super.dispose(); + } + + /// Draws every bridged hitbox in the scene, where its craft is: see + /// [addHitboxes3d]. For looking at why a hit missed; off by default. + bool get debugHitboxes3d => _debugHitboxes3d; + set debugHitboxes3d(bool on) { + _debugHitboxes3d = on; + _applyDebugHitboxes(); + } + + bool _debugHitboxes3d = false; + + void _applyDebugHitboxes() { + final drawing = _renderer; + if (drawing == null) { + return; + } + drawing.debugLines = _debugHitboxes3d + ? (DebugDraw lines) => addHitboxes3d(lines, this) + : null; + } + + /// Uses the renderer: adds a contributor, hands it to a particle pool. + /// Runs once, after [onOpen3d], so what that built is there to be given + /// it. The widget hands the renderer over before Flame has loaded the + /// game, and a hook called at once met a game with nothing built yet. + void onRenderer3d(Renderer renderer) {} + + /// Builds the 3D world: runs once, when [open3d] has run and the game has + /// loaded, in whichever order those came. An override of [onLoad] that + /// wants the world calls `super.onLoad()` first. + void onOpen3d() {} + + /// Opens the world after the game's own loading, not on mount: Flame + /// 1.38 mounts the root game without calling its `onMount`, in a + /// `GameWidget` and in `flame_test` alike. + @override + Future onLoad() async { + await super.onLoad(); + _loaded = true; + _openWhenReady(); + } + + void _openWhenReady() { + if (_scene == null || !_loaded) { + return; + } + if (!_opened) { + _opened = true; + onOpen3d(); + } + final drawing = _renderer; + if (drawing != null && !_rendererUsed) { + _rendererUsed = true; + onRenderer3d(drawing); + } + } + + @override + Color backgroundColor() => const Color(0x00000000); +} diff --git a/packages/flame_flutter3d/lib/src/host/step_clock.dart b/packages/flame_flutter3d/lib/src/host/step_clock.dart new file mode 100644 index 00000000000..d1a7d41b2d4 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/step_clock.dart @@ -0,0 +1,30 @@ +/// What a thing drawn between two steps needs from whatever steps it. +library; + +/// Something drawn between its last two steps: told, before each step, to +/// keep where it is as where it was. +abstract interface class StepFollower { + /// Keeps the present place as the place before the next step. + void rememberPlace(); +} + +/// Whatever steps a simulation: how far the frame is past its last step, and +/// who to tell before each one. +/// +/// **An interface, because three things step and one of them is the game.** +/// `PhysicsStepComponent` steps rigid bodies and `ActorSystemComponent` steps +/// actors; a game whose simulation steps itself in `HasFixedStep.fixedUpdate` +/// — a genre's own `step`, with the actors and the bodies in it — is the third, +/// and a follower handed only the first two could not draw a body the game +/// moved between its places. It remembered after the game's step had already +/// moved it, and drew the body where it already was. +abstract interface class StepClock { + /// How far this frame is past the last step, from 0 up to 1. + double get alpha; + + /// Tells [follower] before each step. + void follow(StepFollower follower); + + /// Stops telling [follower]. + void unfollow(StepFollower follower); +} diff --git a/packages/flame_flutter3d/lib/src/host/transparent_flame_game.dart b/packages/flame_flutter3d/lib/src/host/transparent_flame_game.dart new file mode 100644 index 00000000000..3ca87bb2346 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/transparent_flame_game.dart @@ -0,0 +1,25 @@ +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show Flutter3dFlameWidget; +import 'package:flame_flutter3d/src/host/flutter3d_flame_widget.dart' + show Flutter3dFlameWidget; +import 'package:flutter/painting.dart' show Color; + +/// A [FlameGame] whose background does not paint over +/// [Flutter3dFlameWidget]'s 3D layer. +/// +/// **Every bridged game needs this, and nothing enforces it.** `GameWidget` +/// paints `Game.backgroundColor()` as an opaque `DecoratedBox` behind its own +/// canvas — reasonable for a `GameWidget` on its own, and exactly wrong in +/// the `Stack` [Flutter3dFlameWidget] builds, where that box sits on top of +/// `SceneSurface`. `Game.backgroundColor()` defaults to opaque black, so a +/// game that never overrides it draws a solid black rectangle over the 3D +/// layer every frame — the 3D scene still renders underneath, sized and lit +/// correctly, and nothing on screen shows it. +/// +/// Extend this instead of [FlameGame], or override [backgroundColor] the +/// same way this class does, and whatever the 2D layer does not cover shows +/// the 3D layer through it. +class TransparentFlameGame extends FlameGame { + @override + Color backgroundColor() => const Color(0x00000000); +} diff --git a/packages/flame_flutter3d/lib/src/host/updates_at_root.dart b/packages/flame_flutter3d/lib/src/host/updates_at_root.dart new file mode 100644 index 00000000000..6bc5184bfad --- /dev/null +++ b/packages/flame_flutter3d/lib/src/host/updates_at_root.dart @@ -0,0 +1,68 @@ +import 'package:flame/components.dart'; + +/// A component whose frame's work has to run at a place in the game's own +/// order — after Flame's camera, last of all — wherever the game added it. +/// +/// **A priority orders siblings only.** Flame's `CameraComponent` is a child +/// of the game beside the world, and a component added to the world, where a +/// game adds its components, is updated inside the world's update: before +/// the camera, whatever its priority. A 3D camera synced there trailed +/// `camera.follow()` by a frame; the sound's listener stood where the camera +/// was the frame before; an input step closed there closed before the +/// viewport's buttons had read it. +/// +/// So a component with this, mounted anywhere but the game's root, puts a +/// driver at the root at its own priority and does its work — [rootUpdate] +/// — from there. At the root it does the work itself. +mixin UpdatesAtRoot on Component { + /// The frame's work that has to run at this component's place in the + /// game's own order. + void rootUpdate(double dt); + + /// Whether this component's work has to run at the root at all. True + /// unless overridden; a component that only needs it in one of its modes + /// says which. + bool get needsRoot => true; + + _RootDriver? _driver; + + @override + void onMount() { + super.onMount(); + if (!needsRoot) { + return; + } + final game = findGame(); + if (game == null || identical(parent, game)) { + return; + } + game.add(_driver = _RootDriver(this, priority: priority)); + } + + @override + void onRemove() { + _driver?.removeFromParent(); + _driver = null; + super.onRemove(); + } + + @override + void update(double dt) { + super.update(dt); + if (_driver == null) { + rootUpdate(dt); + } + } +} + +final class _RootDriver extends Component { + _RootDriver(this.owner, {super.priority}); + + final UpdatesAtRoot owner; + + @override + void update(double dt) { + super.update(dt); + owner.rootUpdate(dt); + } +} diff --git a/packages/flame_flutter3d/lib/src/input/flame_input_bridge.dart b/packages/flame_flutter3d/lib/src/input/flame_input_bridge.dart new file mode 100644 index 00000000000..ff744b5914e --- /dev/null +++ b/packages/flame_flutter3d/lib/src/input/flame_input_bridge.dart @@ -0,0 +1,663 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/components.dart' + show + Component, + JoystickComponent, + PositionComponent, + Vector2, + KeyboardHandler; +import 'package:flame/events.dart'; +import 'package:flame/input.dart' show ButtonComponent; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/host/updates_at_root.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' + show + AppLifecycleListener, + AppLifecycleState, + KeyEventResult, + WidgetsBinding, + Focus; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +/// Feeds Flame's own keyboard and drag callbacks into the same +/// [Bindings]/[InputState] pair `flutter3d_game`'s [DesktopInput] and +/// [PadInput] already write into. +/// +/// **Reuses their translation rather than inventing a second one.** A +/// rebind screen, a saved binding file and an [InputState] a `flutter3d_sim` +/// `Actor`'s movement reads all assume there is exactly one of each — that a +/// key means one thing regardless of which widget happened to see it first. +/// If this bridge kept its own key-to-action map, a player who rebinds jump +/// in a native `flutter3d_game` menu would find it unchanged the next time +/// they launched the Flame-hosted build of the same game, because two maps +/// disagreeing is indistinguishable from a rebind that silently failed. So +/// this class owns no mapping of its own: it looks a source up in the very +/// same [Bindings] table [DesktopInput] does, and calls the very same +/// [InputState.press]/[InputState.release] it does, for a source Flame +/// happened to notice instead of a raw [Focus] widget. +/// +/// **A plain Dart class, not a [Component].** Nothing here draws, ticks, or +/// belongs to a scene graph — it is a pair of callbacks a host component +/// forwards its own Flame events to, the same way [DesktopInput] is a plain +/// class a host `Focus.onKeyEvent` forwards to rather than a widget in its +/// own right. +/// +/// ## What this does not do +/// +/// **It does not poll a gamepad.** [PadInput] already reads a pad every +/// tick and writes into this same [InputState] through this same +/// [Bindings] table; a Flame-specific pad translator would be a second +/// implementation of exactly that, drifting from the first the moment +/// either one gains a feature the other doesn't. A Flame game that wants +/// pad support constructs a [PadInput] directly, beside this bridge, both +/// pointed at the shared [inputState]. +final class FlameInputBridge { + FlameInputBridge({required this.bindings, required this.inputState}); + + /// What each key or pointer button does. The same table a rebinding + /// screen edits and [DesktopInput]/[PadInput] read, if the host shares + /// one — see the class doc. + final Bindings bindings; + + /// The shared state a `flutter3d_sim` `Actor`'s movement, and this + /// bridge, both read and write. + final InputState inputState; + + /// Translates a Flame keyboard event into a press or release on + /// [inputState], matching [DesktopInput.handleKeyEvent]'s own logic. + /// + /// Shaped for [KeyboardHandler.onKeyEvent] (`package:flame`), which + /// returns a `bool` rather than Flutter's [KeyEventResult]: `true` means + /// "not mine, keep propagating to the next component or the game itself", + /// `false` means "consumed, stop here" — the opposite polarity of + /// [KeyEventResult.ignored]/[KeyEventResult.handled], but the same + /// question. A source this bridge has nothing bound to is left alone so + /// a Flame game can still use [KeyboardHandler] for its own, unrelated + /// keys. + /// + /// A repeat event is neither a [KeyDownEvent] nor a [KeyUpEvent], so it + /// falls through both branches below and does nothing — exactly what + /// [DesktopInput.handleKeyEvent] does, and for the same reason: treating + /// a repeat as a fresh press would fire an automatic weapon at the + /// keyboard's repeat rate instead of the weapon's. + bool onKeyEvent(KeyEvent event, Set keysPressed) { + final action = bindings[InputSource.key(event.logicalKey.keyId)]; + if (action == null) { + return true; + } + + if (event is KeyDownEvent) { + inputState.press(action); + } else if (event is KeyUpEvent) { + inputState.release(action); + } + return false; + } + + /// [onKeyEvent] for a game rather than a component: the same translation, + /// answered in the [KeyEventResult] that `KeyboardEvents.onKeyEvent` on a + /// `FlameGame` returns. + /// + /// The two Flame hooks ask the same question with opposite answers: a + /// component's `true` means "keep propagating", a game's + /// [KeyEventResult.handled] means "stop". Every game that forwarded to + /// [onKeyEvent] wrote the flip itself, and getting it backwards swallows + /// every key the bridge has nothing bound to. + KeyEventResult onGameKeyEvent( + KeyEvent event, + Set keysPressed, + ) => onKeyEvent(event, keysPressed) + ? KeyEventResult.ignored + : KeyEventResult.handled; + + /// Adds a Flame drag's movement to [inputState]'s accumulated look delta. + /// + /// Reads [DragUpdateEvent.deviceDelta] rather than + /// [DragUpdateEvent.localDelta]: the local variant is only meaningful + /// once Flame has delivered the event to a mounted component through its + /// own hit-testing pipeline, while the device variant is the raw + /// movement the platform reported and is always safe to read — the same + /// unscaled, uncaptured number [DesktopInput.drainLook] takes from a + /// locked pointer. A caller that wants canvas-space movement instead + /// (rare — view-turning cares about raw motion, not where it happened to + /// land on screen) can read the event itself and call [InputState.addLook] + /// directly. + /// + /// [InputState.addLook] accumulates rather than assigns, so this can be + /// called once per drag-update callback exactly as it arrives; whatever + /// step next calls [InputState.endStep] drains the total and starts the + /// next one at zero. + void onDragUpdate(DragUpdateEvent event) { + final delta = event.deviceDelta; + inputState.addLook(delta.x, delta.y); + } + + /// A component that writes [stick]'s deflection into [inputState] every + /// frame, through [InputState.setStickAxis], the call a gamepad's stick + /// goes through: [InputState.moveAxis] then adds it to whatever keys are + /// held, and nothing reading the axis learns where it came from. Add it + /// to the game beside the stick. + /// + /// **Screen-down becomes backwards.** Flame's stick reports down the + /// screen as positive `y`; the move axis calls forward positive, as a key + /// bound to [GameAction.moveForward] does. Every bridged game with a stick + /// wrote the same negation by hand. + /// + /// **At rest it says nothing.** A deflection inside [deadZone], a + /// fraction of the knob's reach, counts as the stick at rest, and a stick + /// at rest writes its zero once, when it comes back, rather than every + /// frame: a pad's stick run beside it, as the class doc suggests, was + /// written over with zero every frame the touch stick was not held. + Component followJoystick(JoystickComponent stick, {double deadZone = 0.0}) => + _JoystickFeed(stick, inputState, deadZone); + + /// A component that reads [pad] into its own state on Flame's clock: in + /// each frame, before the steps of a `HasFixedStep` game, as the touch + /// stick is. + /// + /// **A pad beside the keys had no clock in a Flame game.** `PadInput` + /// reads the controller when it is ticked, and a native game ticks it + /// from its loop; a Flame game had nothing that did, and the pad the + /// class doc suggests running beside this bridge never moved anything. + Component followPad(PadInput pad) => _PadFeed(pad); + + /// A component that feeds this bridge every key the application sees, + /// from the keyboard itself rather than from the game widget's focus. + /// + /// **Keys through `KeyboardEvents` arrive only while the game has the + /// focus.** A button in an overlay, a text field or a menu that takes it + /// takes every key after it — including the key-up of a key the player is + /// still holding, which then stays held for good, since the state counts + /// holds. Read from `HardwareKeyboard`, a key reaches this bridge wherever + /// the focus is, and its release always does. + /// + /// Use this or forward `KeyboardEvents.onKeyEvent` to [onGameKeyEvent], not + /// both: each key would then arrive twice. + Component listenToKeyboard() => _KeyboardFeed([this]); + + /// A component that closes [inputState]'s step after everything that reads + /// it: what a game called by hand as the last line of its `update`, and a + /// game that forgot to call saw a key it pressed once reported as pressed + /// on every frame after. Add it once. + /// + /// In a `HasFixedStep` game the step it closes is the fixed step, after + /// each; otherwise the frame. + /// + /// **A game that reads presses in fixed steps must be a `HasFixedStep` + /// game.** Without it, an `ActorSystemComponent` or `PhysicsStepComponent` + /// counts steps on a clock of its own while this closes the input once a + /// frame: a press made on a frame with no step is closed unseen — every + /// other frame at 120 Hz — and one made on a frame of two steps is seen by + /// both. Only one clock can fix that, and `HasFixedStep` is it. + Component stepEnd() => _InputStepEnd(inputState); + + /// A layer over the canvas that follows the pointer and turns a tap into + /// [press]: [PointerTrack.aim] is where the pointer is, in logical pixels, + /// while it is over the game, and a tap holds [press] down for as long as + /// the finger or the button is. Taps go on to whatever else in Flame is + /// under them. For aiming a turret or a crosshair; put the aim through a + /// `BridgeProjector` to find it on the plane. + PointerTrack pointer({GameAction? press}) => + PointerTrack._(inputState, press); + + /// A layer over the canvas that turns a swipe into a single press of the + /// action for its direction: longer across than [minDistance] logical + /// pixels, and mostly one way. For a frog that hops. + SwipeInput swipes({ + GameAction? up, + GameAction? down, + GameAction? left, + GameAction? right, + double minDistance = 40.0, + }) => SwipeInput._(inputState, up, down, left, right, minDistance); + + /// Holds [action] pressed while [button] is, released when it is let go + /// or the touch is cancelled: an on-screen button for what a key or a pad + /// button does. Replaces whatever the button's own callbacks were. + void bindButton(ButtonComponent button, GameAction action) { + // Three statements, not a cascade: `..onPressed = () => a()..onReleased` + // parses the second assignment into the first closure's body. + button.onPressed = () => inputState.press(action); + button.onReleased = () => inputState.release(action); + button.onCancelled = () => inputState.release(action); + } +} + +/// Updated first among the game's children, before the camera whose +/// viewport holds the stick: it reads the deflection the stick settled on +/// last frame rather than racing the stick's own update to it. +final class _JoystickFeed extends Component { + _JoystickFeed(this.stick, this.inputState, this.deadZone) + : super(priority: BridgePriority.input); + + final JoystickComponent stick; + final InputState inputState; + final double deadZone; + bool _moved = false; + HasFixedStep? _stepped; + + /// In a game of fixed steps the stick is read before the steps, not in + /// this component's update, which runs after them. + @override + void onMount() { + super.onMount(); + final game = findGame(); + if (game is HasFixedStep) { + _stepped = game..beforeSteps(_read); + } + } + + @override + void onRemove() { + _stepped?.removeBeforeSteps(_read); + _stepped = null; + super.onRemove(); + } + + @override + void update(double dt) { + if (_stepped == null) { + _read(); + } + } + + void _read() { + final deflection = stick.relativeDelta; + if (deflection.length > deadZone) { + _moved = true; + inputState.setStickAxis(deflection.x, -deflection.y); + } else if (_moved) { + _moved = false; + inputState.setStickAxis(0.0, 0.0); + } + } +} + +/// Ticks a pad on Flame's clock; made by [FlameInputBridge.followPad]. +final class _PadFeed extends Component { + _PadFeed(this.pad) : super(priority: BridgePriority.input); + + final PadInput pad; + HasFixedStep? _stepped; + double _dt = 0.0; + + @override + void onMount() { + super.onMount(); + final game = findGame(); + if (game is HasFixedStep) { + _stepped = game..beforeSteps(_read); + } + } + + @override + void onRemove() { + _stepped?.removeBeforeSteps(_read); + _stepped = null; + super.onRemove(); + } + + /// A game of fixed steps reads the pad before the steps, with this frame's + /// time from `HasFixedStep.frameSeconds`. It used the time of the frame + /// before, which on the first frame was nought and after a stall scaled a + /// stick's turn by the wrong frame. + @override + void update(double dt) { + _dt = dt; + if (_stepped == null) { + _read(); + } + } + + void _read() => pad.tick(_stepped?.frameSeconds ?? _dt); +} + +/// Several players at one machine, each with their own keys and state. +/// +/// **One keyboard, several bridges.** Two players on one keyboard are two +/// [Bindings] tables and two [InputState]s, and a Flame game has one key +/// handler: each key has to reach the player it is bound for, and a game +/// that forwarded it to the first bridge moved player one with player +/// two's arrows. [onGameKeyEvent] hands it to every player, and a key is +/// handled when any of them has it bound. +/// +/// Each player's pad goes through [FlameInputBridge.followPad]: a `PadInput` +/// over `Gamepad(index: 1)` is the second player's controller, the second +/// to connect, as its light says. +final class PlayerInputs { + PlayerInputs(this.players) : assert(players.isNotEmpty, 'nobody playing'); + + /// Player one first. + final List players; + + /// Every player's step closed, as [FlameInputBridge.stepEnd] closes one. + List stepEnds() => [ + for (final player in players) player.stepEnd(), + ]; + + /// Every player fed from the keyboard itself; see + /// [FlameInputBridge.listenToKeyboard]. + Component listenToKeyboard() => _KeyboardFeed(players); + + /// A game's key event, handed to every player. + KeyEventResult onGameKeyEvent( + KeyEvent event, + Set keysPressed, + ) { + var handled = false; + for (final player in players) { + if (player.onGameKeyEvent(event, keysPressed) == KeyEventResult.handled) { + handled = true; + } + } + return handled ? KeyEventResult.handled : KeyEventResult.ignored; + } +} + +/// The ways of holding a game at one machine, and which of them are players. +/// +/// **An arcade cabinet's join.** Four heroes, two keyboard layouts and up to +/// four controllers: nobody is a player until they press something, and the +/// first to press is player one whatever they are holding. [PlayerInputs] +/// takes its players when it is built; this takes every way of holding the +/// game — each a [FlameInputBridge] with its own bindings and state, and a +/// pad through [FlameInputBridge.followPad] — feeds them all, and lets the +/// game [claim] one as the next player when it sees it pressed. +/// +/// What counts as asking to join, and what a claimed seat chooses — a class, +/// a colour — are the game's; this only keeps who is holding what. +final class PlayerSeats { + PlayerSeats(this.candidates) + : assert(candidates.isNotEmpty, 'nothing to hold'); + + /// Every way of holding the game, whether anybody is yet. + final List candidates; + + final List _seated = []; + + /// The players, in the order they joined: player one first. + List get seated => + List.unmodifiable(_seated); + + /// The ways of holding the game nobody has claimed. + Iterable get free => + candidates.where((FlameInputBridge c) => !_seated.contains(c)); + + /// Makes [candidate] the next player. False when it already is one, is not + /// one of [candidates], or every seat in [limit] is taken. + bool claim(FlameInputBridge candidate, {int limit = 4}) { + if (!candidates.contains(candidate)) { + return false; + } + if (_seated.contains(candidate) || _seated.length >= limit) { + return false; + } + _seated.add(candidate); + return true; + } + + /// Gives [candidate]'s seat up; the players after it move up one. + void release(FlameInputBridge candidate) => _seated.remove(candidate); + + /// Everybody up: back to a screen where nobody has joined. + void releaseAll() => _seated.clear(); + + /// Every candidate's step closed — the unclaimed too, so that a press made + /// to join is seen once and not again. + List stepEnds() => [ + for (final candidate in candidates) candidate.stepEnd(), + ]; + + /// Every candidate fed from the keyboard itself; see + /// [FlameInputBridge.listenToKeyboard]. + Component listenToKeyboard() => _KeyboardFeed(candidates); +} + +/// Feeds bridges from `HardwareKeyboard`; made by +/// [FlameInputBridge.listenToKeyboard] and its several-player siblings. +final class _KeyboardFeed extends Component { + _KeyboardFeed(this.bridges) : super(priority: BridgePriority.input); + + final List bridges; + + @override + void onMount() { + super.onMount(); + HardwareKeyboard.instance.addHandler(_key); + } + + @override + void onRemove() { + HardwareKeyboard.instance.removeHandler(_key); + super.onRemove(); + } + + /// False, always: the key goes on to the focus as well, so a text field in + /// an overlay still gets its letters. + bool _key(KeyEvent event) { + final pressed = HardwareKeyboard.instance.logicalKeysPressed; + for (final bridge in bridges) { + bridge.onKeyEvent(event, pressed); + } + return false; + } +} + +/// Closes the step last of all, from the game's root wherever it was added: +/// inside the world it closed before the viewport's buttons had been read. +final class _InputStepEnd extends Component with UpdatesAtRoot { + _InputStepEnd(this.inputState) : super(priority: BridgePriority.inputEnd); + + /// A game of fixed steps closes the input after each step, not here. + @override + bool get needsRoot => findGame() is! HasFixedStep; + + @override + void rootUpdate(double dt) { + if (_stepped == null) { + inputState.endStep(); + } + } + + final InputState inputState; + HasFixedStep? _stepped; + AppLifecycleListener? _lifecycle; + + /// In a game of fixed steps the input step is a fixed step: closed after + /// each, so a press is seen by one step, and left open through a frame + /// with none, so a press made in it is not lost. + /// + /// **A window that loses focus lets go of every key.** The key-up of a + /// key held while the player switched away never arrives, and the jet + /// flew on turning after an alt-tab. + @override + void onMount() { + super.onMount(); + final game = findGame(); + if (game is HasFixedStep) { + _stepped = game..afterEachStep(inputState.endStep); + } + _lifecycle = _listen(); + } + + /// Null where there is no app to lose focus: a game stepped in a plain + /// Dart test, with no widgets binding. + AppLifecycleListener? _listen() { + final WidgetsBinding binding; + try { + binding = WidgetsBinding.instance; + } on Object { + return null; + } + return AppLifecycleListener( + binding: binding, + onStateChange: (AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + inputState.clear(); + } + }, + ); + } + + @override + void onRemove() { + _stepped?.removeAfterEachStep(inputState.endStep); + _stepped = null; + _lifecycle?.dispose(); + _lifecycle = null; + super.onRemove(); + } +} + +/// Where the pointer is over a Flame game, and a tap as an action; made by +/// [FlameInputBridge.pointer]. +final class PointerTrack extends PositionComponent + with MouseMoveCallbacks, TapCallbacks, DragCallbacks { + PointerTrack._(this._input, this._press); + + final InputState _input; + final GameAction? _press; + + /// Where the pointer last was over the game, in logical pixels from the + /// canvas's top left; null before it has been over it. + Vector2? get aim => _aim; + Vector2? _aim; + + @override + bool containsLocalPoint(Vector2 point) => true; + + @override + void onMouseMove(MouseMoveEvent event) { + _aim = event.canvasPosition.clone(); + } + + /// The pointers down on the game: [_press] is held while there is one. + final Set _down = {}; + final Set _dragging = {}; + + @override + void onTapDown(TapDownEvent event) { + _aim = event.canvasPosition.clone(); + _hold(event.pointerId); + event.continuePropagation = true; + } + + @override + void onTapUp(TapUpEvent event) => _lift(event.pointerId); + + /// **A finger that moves is still down.** Flutter gives up on a tap once + /// the finger slides past a few pixels, and the press was let go with + /// it: firing while dragging to aim stopped the moment the aim moved. + /// A tap given up on for a drag of the same pointer is not let go of; + /// the drag's end is. + @override + void onTapCancel(TapCancelEvent event) { + final pointer = event.pointerId; + scheduleMicrotask(() { + if (!_dragging.contains(pointer)) { + _lift(pointer); + } + }); + } + + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + _dragging.add(event.pointerId); + _hold(event.pointerId); + event.continuePropagation = true; + } + + @override + void onDragUpdate(DragUpdateEvent event) { + super.onDragUpdate(event); + _aim = event.canvasEndPosition.clone(); + } + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + _dragging.remove(event.pointerId); + _lift(event.pointerId); + } + + @override + void onDragCancel(DragCancelEvent event) { + super.onDragCancel(event); + _dragging.remove(event.pointerId); + _lift(event.pointerId); + } + + void _hold(int pointer) { + final action = _press; + if (_down.add(pointer) && _down.length == 1 && action != null) { + _input.press(action); + } + } + + void _lift(int pointer) { + final action = _press; + if (_down.remove(pointer) && _down.isEmpty && action != null) { + _input.release(action); + } + } +} + +/// Swipes over a Flame game as presses; made by [FlameInputBridge.swipes]. +final class SwipeInput extends PositionComponent with DragCallbacks { + SwipeInput._( + this._input, + this._up, + this._down, + this._left, + this._right, + this._minDistance, + ); + + final InputState _input; + final GameAction? _up; + final GameAction? _down; + final GameAction? _left; + final GameAction? _right; + final double _minDistance; + final Vector2 _travel = Vector2.zero(); + + @override + bool containsLocalPoint(Vector2 point) => true; + + /// The drag goes on to what is under it too, a stick say: a layer over + /// the whole canvas that kept it took every drag in the game. + @override + void onDragStart(DragStartEvent event) { + super.onDragStart(event); + _travel.setZero(); + event.continuePropagation = true; + } + + @override + void onDragUpdate(DragUpdateEvent event) => _travel.add(event.canvasDelta); + + @override + void onDragEnd(DragEndEvent event) { + super.onDragEnd(event); + if (_travel.length < _minDistance) { + return; + } + final across = _travel.x.abs() > _travel.y.abs(); + final action = across + ? (_travel.x > 0.0 ? _right : _left) + : (_travel.y > 0.0 ? _down : _up); + if (action == null) { + return; + } + // Pressed and let go in one step: the state reports it pressed this + // step, and held never. + _input + ..press(action) + ..release(action); + } +} diff --git a/packages/flame_flutter3d/lib/src/input/taps3d.dart b/packages/flame_flutter3d/lib/src/input/taps3d.dart new file mode 100644 index 00000000000..0d90ee908e5 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/input/taps3d.dart @@ -0,0 +1,209 @@ +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; + +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/transform/bridged3d.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flame_flutter3d/src/transform/projector.dart'; +import 'package:flame_flutter3d/src/world/wrap_space.dart'; + +/// A bridged component that hears a tap on what it draws in 3D. +/// +/// **Flame's own taps land in the wrong place under a perspective camera.** +/// `TapCallbacks` asks a component whether a point is inside it in Flame's +/// coordinates, which are the plane the game plays on. Seen through a +/// perspective 3D camera, the thing that plane position draws is somewhere +/// else on the screen, larger when near and smaller when far, and a tap on +/// the tanker the player can see missed the tanker Flame thinks is there. +/// This asks instead whether the tap falls on the screen rectangle round +/// what the component draws, through the game's [BridgeProjector]; a +/// [Taps3dComponent] in the game hands the tap to the nearest such +/// component under it. +/// +/// **Down, up, and a finger that stays.** [onTap3d] is the tap going down on +/// it; [onTapUp3d] the finger lifting, [onTapCancel3d] the tap given up on, +/// a drag say, and [onLongTap3d] a finger held still: what Flame's own +/// `TapCallbacks` has, for the same component seen in 3D. +/// +/// Any bridged component: an `Object3dComponent`, or one instance of a +/// batch drawn as an `InstancedObject3dComponent`. +mixin Tap3dCallbacks on Component, HasVisibility, Drawn3d { + /// The tap at [screen], in logical pixels from the top left, fell on + /// this component and on nothing nearer. + void onTap3d(Vector2 screen) {} + + /// The finger that went down on this component lifted at [screen]. + void onTapUp3d(Vector2 screen) {} + + /// The tap that went down on this component was given up on. + void onTapCancel3d() {} + + /// The finger that went down on this component stayed down, still. + void onLongTap3d(Vector2 screen) {} + + /// The boxes in the scene round everything that draws this component: + /// [drawnBounds3d], and in a `WrapSpace` its ghosts across the seam. + Iterable drawnBoxes3d() sync* { + final box = drawnBounds3d; + if (box != null) { + yield box; + } + if ((parent, this) case ( + final WrapSpace space, + final Object3dComponent me, + )) { + yield* space.ghostBoundsOf(me); + } + } + + /// Whether [screen] falls on what this component draws, as [projector] + /// sees it: the screen rectangle round one of [drawnBoxes3d]. Override + /// for a tighter shape. + bool hitAt3d(Vector2 screen, BridgeProjector projector) => + drawnBoxes3d().any((box) => _covers(projector, box, screen)); +} + +bool _covers(BridgeProjector projector, Aabb3 box, Vector2 screen) { + final bounds = projector.boundsOf(box); + return bounds != null && + screen.x >= bounds.left && + screen.x <= bounds.right && + screen.y >= bounds.top && + screen.y <= bounds.bottom; +} + +/// Covers the game's canvas and hands every tap to the nearest +/// [Tap3dCallbacks] component whose drawing it falls on. Add one to a +/// [HasFlutter3d] game. +/// +/// **Nearest to the camera, and one.** Two craft overlapping on the screen +/// are one in front of the other; the tap is for the one in front, and the +/// one behind hears nothing, as a finger on glass would have it. A tap on +/// nothing bridged goes on to whatever else in Flame is under it. +/// +/// **Nearest where the tap meets it, not by its middle.** Measured to the +/// middle of each box, a crate standing on a wide field lost the tap to the +/// field, whose middle was nearer the camera. The distance is to where the +/// ray through the tap enters the box, and to its middle only for a box the +/// ray misses although its screen rectangle is hit. +class Taps3dComponent extends PositionComponent + with TapCallbacks, HasGameRef { + Taps3dComponent({super.priority}); + + /// The component each finger went down on, until it lifts. + final Map _down = {}; + + @override + bool containsLocalPoint(Vector2 point) => true; + + @override + void onTapDown(TapDownEvent event) { + final hit = nearestAt(event.canvasPosition); + if (hit == null) { + event.continuePropagation = true; + return; + } + _down[event.pointerId] = hit; + hit.onTap3d(event.canvasPosition); + } + + @override + void onTapUp(TapUpEvent event) { + final hit = _down.remove(event.pointerId); + if (hit == null) { + event.continuePropagation = true; + return; + } + hit.onTapUp3d(event.canvasPosition); + } + + @override + void onTapCancel(TapCancelEvent event) { + final hit = _down.remove(event.pointerId); + if (hit == null) { + event.continuePropagation = true; + return; + } + hit.onTapCancel3d(); + } + + @override + void onLongTapDown(TapDownEvent event) { + final hit = _down[event.pointerId]; + if (hit == null) { + event.continuePropagation = true; + return; + } + hit.onLongTap3d(event.canvasPosition); + } + + /// The nearest [Tap3dCallbacks] component drawn under [screen], or null. + Tap3dCallbacks? nearestAt(Vector2 screen) { + final ray = gameRef.projector.rayThrough(screen); + final eye = ray?.$1 ?? gameRef.camera3d.readWorldPosition(); + Tap3dCallbacks? nearest; + var nearestDistance = double.infinity; + for (final candidate in gameRef.descendants().whereType()) { + if (!shownInFlame(candidate)) { + continue; + } + if (!candidate.hitAt3d(screen, gameRef.projector)) { + continue; + } + // Of its boxes, the nearest the tap is on: a craft and its ghost are + // never both under one finger, but the one that is decides. + for (final box in candidate.drawnBoxes3d()) { + if (!_covers(gameRef.projector, box, screen)) { + continue; + } + final distance = ray == null + ? eye.distanceTo(box.center) + : _entry(ray.$1, ray.$2, box) ?? eye.distanceTo(box.center); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = candidate; + } + } + } + return nearest; + } + + /// How far from [from] the segment to [to] enters [box], or null when it + /// misses it: the slab test. + static double? _entry(Vector3 from, Vector3 to, Aabb3 box) { + final along = to - from; + final length = along.length; + if (length == 0.0) { + return null; + } + along.scale(1.0 / length); + var enter = 0.0; + var leave = length; + for (var axis = 0; axis < 3; axis++) { + final start = from[axis]; + final step = along[axis]; + final low = box.min[axis]; + final high = box.max[axis]; + if (step.abs() < 1e-12) { + if (start < low || start > high) { + return null; + } + continue; + } + final a = (low - start) / step; + final b = (high - start) / step; + final near = a < b ? a : b; + final far = a < b ? b : a; + if (near > enter) { + enter = near; + } + if (far < leave) { + leave = far; + } + if (enter > leave) { + return null; + } + } + return enter; + } +} diff --git a/packages/flame_flutter3d/lib/src/particles/particles3d_component.dart b/packages/flame_flutter3d/lib/src/particles/particles3d_component.dart new file mode 100644 index 00000000000..dc159e1a92e --- /dev/null +++ b/packages/flame_flutter3d/lib/src/particles/particles3d_component.dart @@ -0,0 +1,112 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_particles/flutter3d_particles.dart'; + +/// A `flutter3d_particles` [ParticleSystem] run on Flame's clock: fire, +/// sparks, debris thrown out where something happened in the game. +/// +/// **What each bridged game with explosions wrote by hand.** A component +/// per blast, a scene node per shard, each node moved, spun, shrunk and +/// taken out again: a hundred nodes and a hundred draws for one depot going +/// up. A particle system is one pool and one instanced draw for every blast +/// on screen, and the particles package already has the emitters, gravity, +/// fading and shrinking a shard was given by hand. +/// +/// This component advances [system] in its [update], so a paused game +/// pauses its fire. It draws through a [MeshParticleContributor] once +/// [drawWith] is given the renderer, which `Flutter3dFlameWidget`'s +/// `onRendererReady` hands over; without one (a test, a server) the +/// particles still live and die, and nothing is drawn. +/// +/// **Light added or light taken away.** Drawn with the default blend, a +/// particle adds light: fire, sparks, a muzzle flash. Drawn with +/// `MeshParticleContributor.darkening`, it takes its colour out of what is +/// behind it: dark smoke, soot. One component is one blend, so a game with +/// both keeps two, each its own pool and its own draw. +class Particles3dComponent extends Component { + Particles3dComponent({required this.system, required this.plane}); + + /// The pool every burst goes into. + final ParticleSystem system; + + /// The plane [burstAt] places a Flame point on. + final BridgePlane plane; + + Renderer? _renderer; + MeshParticleContributor? _contributor; + ({Renderer renderer, DrawableGeometry mesh, BlendState blend})? _drawing; + + /// Draws every particle as a copy of [mesh] through [renderer]'s scene + /// pass, blended by [blend]. Calling it again, with a new renderer after + /// the old one was replaced, moves the drawing there. + /// + /// **Remembered across a removal.** Taken out of the game, the pool stops + /// being drawn; added back, it is drawn again as it was, where it used to + /// be drawn by nothing until [drawWith] was called a second time. + void drawWith( + Renderer renderer, + DrawableGeometry mesh, { + BlendState blend = BlendState.additive, + }) { + _drawing = (renderer: renderer, mesh: mesh, blend: blend); + _startDrawing(); + } + + void _startDrawing() { + final drawing = _drawing; + if (drawing == null) { + return; + } + _stopDrawing(); + _renderer = drawing.renderer; + _contributor = drawing.renderer.addContributor( + MeshParticleContributor(system, mesh: drawing.mesh, blend: drawing.blend), + ); + } + + @override + void onMount() { + super.onMount(); + if (_contributor == null) { + _startDrawing(); + } + } + + /// Throws [effect] out of the Flame point [at] on [plane], lifted + /// [elevation] off it, along the plane's normal. Returns how many + /// particles the pool had room for. + int burstAt( + ParticleEffect effect, + Vector2 at, { + double elevation = 0.0, + Object? source, + }) => system.burst( + effect, + plane.to3d(at, at: plane.constant + elevation), + direction: plane.normal, + source: source, + ); + + @override + void update(double dt) { + super.update(dt); + system.advance(dt); + } + + @override + void onRemove() { + _stopDrawing(); + system.clear(); + super.onRemove(); + } + + void _stopDrawing() { + final contributor = _contributor; + if (contributor != null) { + _renderer?.removeContributor(contributor); + } + _contributor = null; + _renderer = null; + } +} diff --git a/packages/flame_flutter3d/lib/src/physics/character_body_component.dart b/packages/flame_flutter3d/lib/src/physics/character_body_component.dart new file mode 100644 index 00000000000..6e0aca6456c --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/character_body_component.dart @@ -0,0 +1,129 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/collisions.dart' show CollisionCallbacks; +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart' + show CharacterController, CollisionWorld; +import 'package:vector_math/vector_math.dart' show Vector3; + +/// A `CharacterController` with no actor round it, carried across the +/// bridge: the body a platformer's runner moves, Pitfall Harry's. +/// +/// **What the platformer already has, reached from Flame.** A runner in +/// `flutter3d_game_platformer` runs, jumps twice, grabs ladders and ropes, +/// and moves a character controller; what it lacked on Flame's side was a +/// component that steps it with the game and puts it where it is. [drive] +/// is that step, `runner.step(dt, input)` say, and runs in the game's fixed +/// steps when the game has `HasFixedStep`, once a frame otherwise. The body's +/// place is written onto the node and read back to Flame, drawn between its +/// last two steps when the steps are fixed. +/// +/// **A body the game moves itself** — a genre whose own `step` moves its +/// heroes, called from the game's `fixedUpdate` — has no [drive] and is +/// handed the game as its [stepper]. It is then told to keep its place at +/// the start of each step, before the game moves it. Keeping it in its own +/// `fixedUpdate`, which runs after the game's, kept the place the game had +/// already moved it to, and the body was drawn with no smoothing at all. +class CharacterBodyComponent extends Object3dComponent + with CollisionCallbacks, FixedStepUpdate + implements StepFollower { + CharacterBodyComponent({ + required this.body, + required super.node, + required super.scene, + required super.plane, + this.drive, + this.stepper, + this.removeFrom, + super.size, + super.anchor, + super.priority, + }); + + /// The body being moved. + final CharacterController body; + + /// What moves [body] by one step of the given seconds. + final void Function(double dt)? drive; + + /// What steps [body] when [drive] does not, and tells this component to + /// keep its place before each step: the game, for a body the game's own + /// simulation moves. Null keeps the place in this component's own step. + final StepClock? stepper; + + @override + void onMount() { + super.onMount(); + _stepped = false; + stepper?.follow(this); + } + + @override + void rememberPlace() { + _before.setFrom(body.position); + _stepped = true; + } + + /// The world [body]'s collider leaves when this component leaves the game; + /// null leaves it to whoever built it. A despawned character otherwise + /// stayed in the world, unseen and solid — `RigidBodyComponent.removeFrom` + /// says the same of a crate, and is taken out the same way. + final CollisionWorld? removeFrom; + + @override + void onRemove() { + stepper?.unfollow(this); + final world = removeFrom; + if (world != null) { + final collider = body.collider; + scheduleMicrotask(() { + if (!isMounted && parent == null && identical(collider.world, world)) { + world.remove(collider); + } + }); + } + super.onRemove(); + } + + final Vector3 _before = Vector3.zero(); + final Vector3 _drawn = Vector3.zero(); + bool _stepped = false; + + /// Carries the body across too, still moving; see + /// `RigidBodyComponent.shiftScene`. + @override + void shiftScene(Vector3 by) { + super.shiftScene(by); + body.position.add(by); + body.collider + ..position.setFrom(body.position) + ..refreshBounds(); + _before.add(by); + } + + @override + void fixedUpdate(double step) { + if (stepper == null) { + rememberPlace(); + } + drive?.call(step); + } + + @override + void update(double dt) { + final game = findGame(); + if (game is! HasFixedStep) { + drive?.call(dt); + } + final clock = stepper ?? (game is HasFixedStep ? game : null); + if (clock != null && _stepped) { + Vector3.mix(_before, body.position, clock.alpha, _drawn); + placeNode(_drawn); + } else { + placeNode(body.position); + } + super.update(dt); + } +} diff --git a/packages/flame_flutter3d/lib/src/physics/collider_registry.dart b/packages/flame_flutter3d/lib/src/physics/collider_registry.dart new file mode 100644 index 00000000000..c9ae5364894 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/collider_registry.dart @@ -0,0 +1,130 @@ +import 'package:flame/collisions.dart' show CollisionCallbacks; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/physics/collision_bridge.dart'; +import 'package:flame_flutter3d/src/physics/physics_step_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart'; + +/// Which Flame component each collider belongs to, for a [CollisionBridge] +/// to hand over as the other side of a contact. +/// +/// **The map every game with contacts kept by hand.** A collider knows +/// nothing of Flame, so `resolveOther` had to be answered from a map the game +/// filled when it made a body and emptied when the body went, and a body +/// removed without emptying it was a contact reported against a component no +/// longer in the game. Here an entry leaves when its component is removed +/// from the game, on its own, and comes back if the component is added +/// again, a pooled ship say. A component moved to another parent keeps it: +/// Flame moves by removing and mounting at once, and the move dropped the +/// entry for good. +final class ColliderRegistry { + final Map _components = + {}; + + /// Which registration of a collider is the live one: a watch left over + /// from an earlier one, or from before [unregister], does nothing. + final Expando _tickets = Expando(); + + /// [collider] belongs to [component] while [component] is in a game, + /// until [unregister] is called. + void register(Collider collider, PositionComponent component) { + final ticket = _tickets[collider] = Object(); + _components[collider] = component; + _watch(collider, component, ticket); + } + + void _watch(Collider collider, PositionComponent component, Object ticket) { + component.removed.then((_) { + if (!identical(_tickets[collider], ticket)) { + return; + } + if (component.isMounted) { + _watch(collider, component, ticket); + return; + } + _components.remove(collider); + component.mounted.then((_) { + if (!identical(_tickets[collider], ticket)) { + return; + } + _components[collider] = component; + _watch(collider, component, ticket); + }); + }); + } + + /// [collider] belongs to nothing any more. + void unregister(Collider collider) { + _tickets[collider] = null; + _components.remove(collider); + } + + /// The component [collider] belongs to, or null for level geometry and + /// anything else nothing on the Flame side stands for. + PositionComponent? componentFor(Collider collider) => _components[collider]; + + /// Relays [collider]'s contacts to [component], the other side of each + /// looked up here. [collider] is usually [component]'s body's, and may be a + /// sensor riding on it. + /// + /// Handed [stepper], its `onCollision` comes once a frame, as Flame's does; + /// see [CollisionBridge.stepper]. + CollisionBridge bridge({ + required Collider collider, + required CollisionCallbacks component, + PhysicsStepComponent? stepper, + }) => CollisionBridge( + collider: collider, + component: component, + resolveOther: componentFor, + stepper: stepper, + ); + + /// Fires a ray across [plane] from [from] to [to], in Flame's coordinates + /// [lift] off the plane, through [world], and says what it met first: the + /// component it belongs to, if one is registered, where on the plane, and + /// the collider. + /// + /// **What a Flame game could not ask the world.** A turret's line of + /// sight, a laser's reach, a grenade's arc checked against a wall: the + /// world answers them exactly, per shape, and a game reached for Flame's + /// own raycast, which knows only Flame's hitboxes and none of the level. + /// [mask] is the layers it can see, as a collider's is; triggers are + /// seen only when asked for. + ({PositionComponent? component, Vector2 point, Collider collider})? raycast( + CollisionWorld world, + BridgePlane plane, + Vector2 from, + Vector2 to, { + double lift = 0.0, + int mask = Layers.all, + Collider? ignore, + bool includeTriggers = false, + }) { + final start = plane.to3d(from, at: plane.constant + lift); + final along = plane.to3d(to, at: plane.constant + lift)..sub(start); + final length = along.length; + if (length == 0.0) { + return null; + } + along.scale(1.0 / length); + final hit = RayHit(); + if (!world.raycast( + start, + along, + length, + hit, + mask: mask, + ignore: ignore, + includeTriggers: includeTriggers, + )) { + return null; + } + final met = hit.collider!; + return ( + component: componentFor(met), + point: plane.to2d(hit.point), + collider: met, + ); + } +} diff --git a/packages/flame_flutter3d/lib/src/physics/collision_bridge.dart b/packages/flame_flutter3d/lib/src/physics/collision_bridge.dart new file mode 100644 index 00000000000..7e2d8d04a94 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/collision_bridge.dart @@ -0,0 +1,220 @@ +/// Re-fires flutter3d's own [CollisionListener] events as calls into +/// Flame's [CollisionCallbacks] surface, for one [Collider] at a time. +library; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show RigidBodyComponent; +import 'package:flame_flutter3d/src/physics/physics_step_component.dart'; +import 'package:flame_flutter3d/src/physics/rigid_body_component.dart' + show RigidBodyComponent; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart'; + +/// A plain Dart object, not a [Component] — it draws nothing and has no +/// per-frame update of its own. All it does is sit as [collider]'s +/// [CollisionListener] and translate what [CollisionWorld] tells it into +/// calls on [component]'s [CollisionCallbacks] methods. +/// +/// Constructing one attaches it: `collider.listener = this` happens in the +/// constructor, so a caller wires a bridge into the world simply by building +/// it, the same way `RigidBodyComponent` needs no separate "activate" step +/// once it exists. +/// +/// ## The reference mismatch, and why nothing here papers over it +/// +/// flutter3d's [CollisionListener] reports a pair of [Collider]s. +/// [CollisionCallbacks] wants a [PositionComponent]. `flutter3d_physics` +/// does not know Flame exists, so a [Collider] never carries a component +/// back to hand one over — a caller has to say how to find it, and +/// [resolveOther] is that answer: a lookup into whatever registry of +/// collider-to-component the caller already keeps (one entry per bridged +/// [RigidBodyComponent], typically). **When [resolveOther] returns null — +/// the other side of the contact is level geometry, a physics-only body +/// with no Flame component, or anything else nothing on the Flame side +/// represents — this bridge calls nothing.** There is no +/// [PositionComponent] to hand [CollisionCallbacks] in that case, and +/// inventing one, or routing the event to [component] with a null other, +/// would tell Flame code something untrue: that it collided with something +/// that, from Flame's point of view, does not exist. This is the collision +/// contact shape mismatch flagged as a real design commitment rather than +/// an oversight — silence is the correct behaviour, not a gap to fill +/// later. +/// +/// ## The contact shape mismatch +/// +/// flutter3d's collision system reports overlap as a pair of colliders — +/// there is no manifold, and [Contact] (built by `contactBetween`, which +/// this class does not call) carries only a normal and a depth even when +/// something does compute one. Flame's own signature has no room for either: +/// [CollisionCallbacks.onCollisionStart] and `.onCollision` take a +/// `List` of intersection points and nothing else. This bridge does +/// not try to synthesize a normal or a depth into that set — it has nowhere +/// to put them, and inventing a fake one would be worse than sending none. +/// What it sends instead is the cheapest honest stand-in for "roughly where +/// this touched": the midpoint of the two colliders' centres, projected +/// through [component]'s own [BridgePlane] via `plane.to2d`. For two boxes +/// of the same size that midpoint is the middle of their overlap; for boxes +/// of different sizes it can fall outside it (a 0.6 sensor meeting a 0.4 +/// box 0.9 apart overlaps over [0.5, 0.6], and the midpoint is 0.45). That +/// is close enough to "where they touch" for a callback whose real job is +/// handing over a component +/// reference, not reporting physics. A caller that needs the actual normal +/// or depth reads [Collider.listener]'s own flutter3d-side callback +/// directly — this bridge relays the event onward, it does not replace the +/// flutter3d-side one. +final class CollisionBridge with CollisionListener { + CollisionBridge({ + required this.collider, + required this.component, + required this.resolveOther, + this.stepper, + BridgePlane? plane, + }) : plane = + plane ?? + (component is Object3dComponent + ? (component as Object3dComponent).plane + : throw ArgumentError.value( + component, + 'component', + 'is not bridged, so a plane has to be given', + )) { + collider.listener = this; + } + + /// The flutter3d collider whose events this bridge relays. Bridged the + /// moment this object is constructed. + final Collider collider; + + /// The Flame-side component [collider] belongs to — the target every + /// relayed callback lands on. A `RigidBodyComponent`, an `ActorComponent`, + /// or any component with Flame's collision callbacks. + /// + /// **Any of them, not only a rigid body's.** An actor's body has a + /// collider as a crate's does, and a bot touching the ship could not be + /// told so through this bridge. + final CollisionCallbacks component; + + /// Where a contact's midpoint is put on Flame's side: [component]'s own + /// plane when it is bridged. + final BridgePlane plane; + + /// Finds the [PositionComponent] bridged to the *other* collider in a + /// contact, or null when nothing on the Flame side represents it. + /// + /// Typically a lookup into a `Map` the + /// caller keeps — one entry per bridged component — since a bare + /// [Collider] carries nothing back to whatever Flame component (if any) + /// it belongs to. + final PositionComponent? Function(Collider other) resolveOther; + + /// What steps the world, when [onCollision] should come once a frame, as + /// Flame's own collision detection calls it, rather than once a step. + /// + /// **A frame of three steps touched three times.** The world reports an + /// overlap after every step, and a damage-over-time written against + /// Flame's once-a-frame `onCollision` took three times the damage on a + /// slow frame and none on a frame with no step. Handed the stepper, this + /// relays it once for each partner in each frame the two touch. The start + /// and the end of a contact are events, and are told when they happen. + final PhysicsStepComponent? stepper; + + final Map _toldInFrame = {}; + + /// Stops relaying: clears [collider]'s listener, if it is still this + /// bridge, and leaves it alone if something else has taken it since. + /// + /// For a collider that outlives its component, a body put back in a pool + /// say. A bridge whose collider leaves the world with its component needs + /// no call, and a removed [component] hears nothing either way (see + /// [onCollisionStart]). + void detach() { + if (identical(collider.listener, this)) { + collider.listener = null; + } + } + + /// Relays the start of a contact to [component], unless [resolveOther] + /// finds nothing on the Flame side for [other] or [component] has been + /// removed from its game: Flame's own collision system does not call a + /// removed component either, and a despawned ship hearing it hit a bot + /// is a callback into a game object that is gone. + @override + void onCollisionStart(Collider self, Collider other) { + if (component.isRemoved) { + return; + } + final target = resolveOther(other); + if (target == null) { + return; + } + if (_touching.add(target)) { + _endWhenGone(target); + } + component.onCollisionStart(_pointFor(self, other), target); + } + + @override + void onCollision(Collider self, Collider other) { + if (component.isRemoved) { + return; + } + final target = resolveOther(other); + if (target == null) { + return; + } + final steps = stepper; + if (steps != null) { + if (_toldInFrame[target] == steps.frame) { + return; + } + _toldInFrame[target] = steps.frame; + } + component.onCollision(_pointFor(self, other), target); + } + + @override + void onCollisionEnd(Collider self, Collider other) { + if (component.isRemoved) { + return; + } + final target = resolveOther(other); + if (target == null || !_touching.remove(target)) { + return; + } + _toldInFrame.remove(target); + component.onCollisionEnd(target); + } + + /// What [component] is touching, as far as it has been told. + final Set _touching = {}; + + /// **A partner removed mid-contact ends the contact.** Flame's own + /// hitboxes end both sides of a contact when one of them goes; here the + /// world said nothing, the other side was never told, and it went on + /// counting the removed one among its `activeCollisions`. Checked a + /// moment after the removal, since Flame moves a component to a new + /// parent by removing and mounting it. + void _endWhenGone(PositionComponent target) { + target.removed.then((_) { + if (target.isMounted || target.parent != null) { + if (_touching.contains(target)) { + _endWhenGone(target); + } + return; + } + if (_touching.remove(target) && !component.isRemoved) { + component.onCollisionEnd(target); + } + }); + } + + /// The midpoint of [self] and [other]'s centres, on [component]'s plane, + /// as the one-element point list Flame's callback signature wants. See + /// this class's own doc comment for why a midpoint and not a real contact + /// point. + List _pointFor(Collider self, Collider other) => [ + plane.to2d((self.position + other.position) * 0.5), + ]; +} diff --git a/packages/flame_flutter3d/lib/src/physics/kinematic_body_component.dart b/packages/flame_flutter3d/lib/src/physics/kinematic_body_component.dart new file mode 100644 index 00000000000..3a57e99e640 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/kinematic_body_component.dart @@ -0,0 +1,99 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart'; + +/// A collider Flame moves: a lift, an escalator's step, a platform on a +/// path, moved with Flame's own effects and carrying whoever stands on it. +/// +/// **The other way round from `RigidBodyComponent`.** A rigid body is moved +/// by the solver and Flame reads where it went; a lift is moved by the game, +/// a `MoveEffect` up and down or a `MoveAlongPathEffect` round a loop, and +/// the world has to be told. Written into the collider by hand, the lift +/// moved and its passenger stayed where it was: a character is carried by +/// the motion `Collider.moveTo` records, and only by that. +/// +/// So each step the collider is moved to where Flame has put this +/// component, through `moveTo`, and a step in which it did not move clears +/// what the last one recorded, so a passenger is carried once for each move +/// and not again on every step after it. The collider should be of +/// `ColliderKind.kinematic`. A conveyor's belt is `Collider.surfaceVelocity`, +/// set on it directly. +/// +/// Updated before the actors and the physics +/// ([BridgePriority.kinematic]), in the game's steps when it has +/// `HasFixedStep`: a passenger stepped before its lift moved would stand a +/// step behind it. In such a game the lift follows where Flame put it the +/// frame before, since the steps run before this frame's effects. +class KinematicBodyComponent extends Object3dComponent with FixedStepUpdate { + KinematicBodyComponent({ + required this.collider, + required super.node, + required super.scene, + required super.plane, + this.removeFrom, + super.elevation, + super.position, + super.size, + super.anchor, + super.angle, + super.children, + super.priority = BridgePriority.kinematic, + super.key, + }) : super(direction: SyncDirection.flameToScene); + + /// What the world sees of this: moved to where Flame puts it. + final Collider collider; + + /// The world [collider] leaves when this component leaves the game; null + /// leaves it to whoever built it. A lift removed from Flame otherwise + /// stayed in the world, unseen, for passengers to stand on. + final CollisionWorld? removeFrom; + + bool _stepped = false; + + @override + void onRemove() { + final world = removeFrom; + if (world != null) { + scheduleMicrotask(() { + if (!isMounted && parent == null && identical(collider.world, world)) { + world.remove(collider); + } + }); + } + super.onRemove(); + } + + @override + void onMount() { + super.onMount(); + _stepped = findGame() is HasFixedStep; + } + + @override + void fixedUpdate(double step) => _carry(); + + /// Moved once a frame, after the effects under it have, when the game + /// has no fixed steps: before the physics and the actors, which are later + /// siblings. + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + if (!_stepped) { + _carry(); + } + } + + void _carry() { + final at = scenePosition; + final now = collider.position; + if (at.x == now.x && at.y == now.y && at.z == now.z) { + collider.clearDelta(); + } else { + collider.moveTo(at); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart b/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart new file mode 100644 index 00000000000..d544d8e3f38 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart @@ -0,0 +1,135 @@ +/// [PhysicsStepComponent] steps one shared flutter3d_physics world, once a +/// frame, wherever Flame's own game loop already is. +library; + +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' + show ActorSystemComponent, CollisionBridge; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flame_flutter3d/src/physics/rigid_body_component.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show FixedStep; + +/// The one place a bridged game's frame steps its [Dynamics] and dispatches +/// its [CollisionWorld]'s contacts. +/// +/// **The physics half of what [ActorSystemComponent] is for actors.** A +/// [RigidBodyComponent] never steps anything, for the reason that class +/// gives: a hundred bridged crates each stepping the shared world would +/// step it a hundred times a frame. Something has to step it once, and every +/// game that used the bridge wrote this component for itself: the arcade, +/// the package's own example, a showcase page. It lives here now. +/// +/// **Step, then whatever follows a body, then dispatch.** [update] calls +/// [Dynamics.step], then [afterStep], then [CollisionWorld.update], in that +/// order, because the last of them is what sends overlaps to every listener, +/// a [CollisionBridge] among them. Dispatching before the step would report +/// last frame's overlaps against this frame's picture. [afterStep] is the +/// seam for anything that follows a body the solver just moved and has to +/// be in place before the dispatch: a trigger sensor that rides on a solid +/// body, for instance, since two solids never overlap and only the sensor +/// can report them touching. +/// +/// **In fixed steps, not in frames.** Flame's `dt` is whatever the frame +/// took: a sixtieth, a hundred-and-twentieth, a quarter of a second when a +/// laptop stalls. Integrated as it comes, the same jump reaches a different +/// height on a faster screen and a hitch lets a fast body step through a +/// wall. [step] spends the frame's time in whole steps of one size, at most +/// its `maxStepsPerFrame` of them, and keeps the remainder for the next +/// frame; contacts are dispatched after each step, so none is missed +/// between two. [alpha] is how far the frame is past the last step, and a +/// [RigidBodyComponent] handed this component draws its body that far +/// between its last two places rather than jumping from one to the next. +/// +/// **Order it before whatever reads the result.** Flame updates components +/// by ascending priority; give this one a priority below the components that +/// read positions or react to contacts, as [ActorSystemComponent] is given +/// one below the actors' readers. +/// +/// **In a `HasFixedStep` game it steps with the game**, once in each of the +/// game's steps, and [step] is not used: see [HasFixedStep]. +final class PhysicsStepComponent extends Component + with FixedStepUpdate + implements StepClock { + PhysicsStepComponent({ + required this.dynamics, + required this.world, + this.afterStep, + FixedStep? step, + super.priority = BridgePriority.physics, + }) : step = step ?? FixedStep(); + + /// The bodies this steps. + final Dynamics dynamics; + + /// The world whose contacts this dispatches after the step. + final CollisionWorld world; + + /// Runs between the solver and the dispatch, once a step. Null for a + /// game with nothing to move there. + final void Function()? afterStep; + + /// How the frame's time is cut into steps: one sixtieth of a second each + /// unless given otherwise. + final FixedStep step; + + /// How far this frame is past the last step, from 0 up to 1: the game's, + /// when the game steps it. + @override + double get alpha => _game?.alpha ?? step.alpha; + + HasFixedStep? _game; + + @override + void onMount() { + super.onMount(); + _game = switch (findGame()) { + final HasFixedStep game => game, + _ => null, + }; + } + + final Set _followers = {}; + + /// [body] is told where its body was before each step, so it can draw + /// between that and where the step put it. [RigidBodyComponent] does + /// this for itself when handed this component. + @override + void follow(StepFollower body) => _followers.add(body); + + /// Stops telling [body]; it is removed, or no longer interpolates. + @override + void unfollow(StepFollower body) => _followers.remove(body); + + /// Counts the frames this has been updated in: the steps of one frame all + /// see the same number. What a `CollisionBridge` handed this tells one + /// frame's `onCollision` from the next by. + int get frame => _frame; + int _frame = 0; + + @override + void update(double dt) { + super.update(dt); + _frame++; + if (_game != null) { + return; + } + final steps = step.advance(dt); + for (var i = 0; i < steps; i++) { + fixedUpdate(step.stepSeconds); + } + } + + /// One step of the world, of [seconds], and its contacts. + @override + void fixedUpdate(double seconds) { + for (final body in _followers) { + body.rememberPlace(); + } + dynamics.step(seconds); + afterStep?.call(); + world.update(); + } +} diff --git a/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart b/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart new file mode 100644 index 00000000000..e9e3a2f7f69 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart @@ -0,0 +1,172 @@ +/// A physics-authoritative [RigidBody] kept at the same place as a flutter3d +/// [SceneNode] and a Flame [PositionComponent]. +library; + +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show CollisionBridge; +import 'package:flame_flutter3d/src/host/step_clock.dart'; +import 'package:flame_flutter3d/src/physics/collision_bridge.dart' + show CollisionBridge; +import 'package:flame_flutter3d/src/physics/physics_step_component.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; + +/// Bridges one [RigidBody] onto a flutter3d [SceneNode] and, through +/// [Object3dComponent], onto a Flame [PositionComponent] — and, through the +/// [CollisionCallbacks] this mixes in, onto the method surface +/// [CollisionBridge] relays flutter3d's own collision events into. +/// +/// **[body] is built and stepped elsewhere.** Constructing a [RigidBody] +/// already adds it to the [CollisionWorld] it names, and almost every caller +/// also hands it to a [Dynamics] the way `Dynamics.add` wants — so by the +/// time a [RigidBodyComponent] wraps one, both have very likely already +/// happened. This component never calls `Dynamics.step` itself: exactly one +/// thing should step a shared simulation once a frame, the way a single +/// `ActorSystemComponent` would centralize `ActorSystem.step` rather than +/// letting every actor-bridging component step its own copy — a hundred +/// `RigidBodyComponent`s each stepping the same `Dynamics` is a hundred steps +/// a frame, and the bug that produces is "everything moves too fast," which +/// is a strange place to have to go looking for "a component and a game loop +/// both call step." +/// +/// **Defaults to [SyncDirection.sceneToFlame].** [SyncDirection]'s own doc +/// comment already says a rigid body is scene-authoritative: the solver +/// decides where it is, and Flame's `position` is a read of that decision, +/// never a write into it. A caller that truly wants a Flame-driven body — an +/// input-controlled crate, say — should not reach for this component at all; +/// nothing here supports writing a Flame position back onto a [RigidBody]'s +/// [Collider], because [Collider.moveTo] is [Dynamics]'s to call, not a +/// transform bridge's. +class RigidBodyComponent extends Object3dComponent + with CollisionCallbacks + implements StepFollower { + RigidBodyComponent({ + required this.body, + required super.node, + required super.scene, + required super.plane, + this.stepper, + this.removeFrom, + super.direction, + super.elevation, + super.position, + super.size, + super.anchor, + super.angle, + super.scale, + super.children, + super.priority, + super.key, + }); + + /// The physics body this component tracks. + /// + /// Owned by whoever built it — this component never constructs the body, + /// and removes it from its [CollisionWorld] only when handed [removeFrom]; + /// otherwise it reads [RigidBody.position] every frame and nothing else. + final RigidBody body; + + /// What steps [body], when this should draw between its steps: the frame + /// usually falls between two, and a body drawn where the last step left + /// it moves in sixtieth-of-a-second jumps on a screen that shows more. + /// Null draws it where it is. + final PhysicsStepComponent? stepper; + + /// The dynamics [body] leaves, and its collision world with it, when this + /// component leaves the game; null leaves it to whoever built it. + /// + /// **A despawned crate was still solid.** With the body left behind, a + /// crate removed from Flame stayed in the world, unseen, for everything + /// to bump into. Taken out when the component is gone, not when Flame + /// moves it to a new parent, and never from inside a contact: Flame + /// removes components at the start of a frame, between steps. + final Dynamics? removeFrom; + + final Vector3 _before = Vector3.zero(); + final Vector3 _drawn = Vector3.zero(); + bool _remembered = false; + + /// Keeps where [body] is now as where it was before the next step. Called + /// by [stepper] before each step. + @override + void rememberPlace() { + _before.setFrom(body.position); + _remembered = true; + } + + /// Puts [body] at [to], still and awake, and draws it there at once. + /// + /// **Moved, not slid.** Written straight into the collider, a respawned + /// body was drawn sliding across the level from where it had been, since + /// the drawing runs between the last two steps, and a body asleep where + /// it was stayed asleep in the air where it went. + void teleport(Vector3 to) { + body.collider + ..moveTo(to) + ..clearDelta(); + body + ..velocity.setZero() + ..wake(); + _before.setFrom(to); + placeNode(to); + } + + /// Carries the body across too, still moving, and where it was before the + /// step with it, so it is not drawn sliding back across the world. + @override + void shiftScene(Vector3 by) { + super.shiftScene(by); + body.collider + ..moveTo(body.position + by) + ..clearDelta(); + _before.add(by); + } + + @override + void onMount() { + super.onMount(); + // Added again, it draws from where the body is, not from where it was + // when it went. + _remembered = false; + stepper?.follow(this); + } + + @override + void onRemove() { + stepper?.unfollow(this); + final dynamics = removeFrom; + if (dynamics != null) { + scheduleMicrotask(() { + if (!isMounted && parent == null && dynamics.bodies.contains(body)) { + dynamics.remove(body); + } + }); + } + super.onRemove(); + } + + /// Copies [body]'s current position onto [node], then defers to + /// [Object3dComponent.update] to carry that onto the Flame side. + /// + /// The same line every current caller of `flutter3d_physics` already + /// writes by hand — `mesh.setPositionFrom(body.position)` in + /// `apps/flutter3d_showcase/lib/pages/physics_particles/rigid_bodies.dart` + /// — generalized once here so a Flame-bridged body does not need a + /// bespoke per-frame update to stay honest about where its physics really + /// put it. + @override + void update(double dt) { + final steps = stepper; + if (steps != null && _remembered) { + Vector3.mix(_before, body.position, steps.alpha, _drawn); + placeNode(_drawn); + } else { + placeNode(body.position); + } + super.update(dt); + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/billboard_atlas.dart b/packages/flame_flutter3d/lib/src/transform/billboard_atlas.dart new file mode 100644 index 00000000000..b9952bc63e4 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/billboard_atlas.dart @@ -0,0 +1,148 @@ +import 'dart:typed_data' show Float32List; +import 'dart:ui' as ui; + +import 'package:flame/components.dart' show Sprite, TextPaint; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:vector_math/vector_math.dart' show Matrix4; + +/// The pictures sprite billboards are drawn with, shared: one texture and +/// one material for each image, one card for each part of an image a frame +/// shows. +/// +/// **Forty reeds, one texture.** A billboard alone uploads its image and +/// makes its own cards, and a bank of reeds drawn from one sprite sheet +/// uploaded the sheet once for each reed. Handed an atlas, every billboard +/// of an image draws with the one texture and the one material, and the +/// cards are made once for all of them. +/// +/// What it made is the game's until [dispose], which gives it back after the +/// frames in flight when handed the renderer. +final class BillboardAtlas { + BillboardAtlas(this.device); + + final GraphicsDevice device; + + /// Keyed by how it is sampled as well as by the picture: a caller asking + /// for [materialOf] `smooth` after another asked for it sharp got the + /// sharp one. + final Map<(ui.Image, bool), Future> _materials = + <(ui.Image, bool), Future>{}; + + /// Set by [dispose]. An upload still reading its pixels when the atlas is + /// disposed checks it before making a texture nothing would give back. + bool _disposed = false; + final List _textures = []; + final Map<(ui.Image, double, double, double, double), DeviceMesh> _cards = + <(ui.Image, double, double, double, double), DeviceMesh>{}; + + static final MeshData _quad = const PlaneShape().build().transformed( + Matrix4.translationValues(0.0, 0.5, 0.0) + ..multiply(Matrix4.rotationX(1.5707963267948966)), + ); + + /// The material [image] is drawn with: unlit, cut out where it is clear, + /// both sides, sampled nearest for pixel art or, [smooth], linearly for + /// lettering and anything drawn at a finer grain. Uploaded the first time + /// it is asked for in each sampling; null if the image cannot be read, or + /// if the atlas was disposed while it was being read. + Future materialOf(ui.Image image, {bool smooth = false}) => + _materials.putIfAbsent((image, smooth), () async { + final pixels = await image.toByteData( + format: ui.ImageByteFormat.rawStraightRgba, + ); + if (pixels == null || _disposed) { + return null; + } + final texture = device.createTextureFromPixels( + width: image.width, + height: image.height, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: pixels, + ); + if (texture == null) { + return null; + } + _textures.add(texture); + return engine.Material( + name: 'sprite', + lighting: LightingModel.unlit, + albedo: texture, + albedoSampler: smooth + ? SamplerOptions.linearClamp + : SamplerOptions.nearestClamp, + alphaMode: MaterialAlphaMode.mask, + doubleSided: true, + ); + }); + + /// A card showing the part of its image [sprite] is cut from: a quad a + /// metre square facing +Z, its foot at the origin. Its own corners rather + /// than a texture transform, which not every lighting model reads. + DeviceMesh cardOf(Sprite sprite) { + final image = sprite.image; + final at = sprite.srcPosition; + final size = sprite.srcSize; + return _cards.putIfAbsent((image, at.x, at.y, size.x, size.y), () { + final uv = _quad.layout.floatOffsetOf(VertexLayout.texcoord.name); + final stride = _quad.layout.floatsPerVertex; + final vertices = Float32List.fromList(_quad.vertices); + for (var i = uv; i >= 0 && i < vertices.length; i += stride) { + vertices[i] = (at.x + vertices[i] * size.x) / image.width; + vertices[i + 1] = (at.y + vertices[i + 1] * size.y) / image.height; + } + return DeviceMesh.upload( + device, + MeshData( + layout: _quad.layout, + vertices: vertices, + indices: _quad.indices, + ), + ); + }); + } + + /// Writes [text] with Flame's [paint] into a picture of its own, [margin] + /// pixels clear round it, for a billboard to stand in the scene: a sign + /// by the road, a name over a craft, a score where a target went down. + /// + /// **Flame's text, not a font of the bridge's.** Whatever a `TextPaint` + /// draws on Flame's canvas, its font, weight, colour and shadows, is what + /// the sign says; write it large, as the card is sampled from it, and + /// draw it `smooth` in its billboard. + static Future spriteOfText( + String text, + TextPaint paint, { + double margin = 4.0, + }) { + final painter = paint.toTextPainter(text); + final width = (painter.width + margin * 2.0).ceil(); + final height = (painter.height + margin * 2.0).ceil(); + final recorder = ui.PictureRecorder(); + painter.paint(ui.Canvas(recorder), ui.Offset(margin, margin)); + return recorder.endRecording().toImage(width, height).then(Sprite.new); + } + + /// Gives back every texture and card, after the frames [drawing] may still + /// have in flight when it is given. + void dispose({Renderer? drawing}) { + _disposed = true; + for (final card in _cards.values) { + if (drawing != null) { + drawing.releaseMeshAfterFrame(card); + } else { + device + ..releaseGeometry(card.vertices) + ..releaseGeometry(card.indices); + } + } + // After the frames in flight too, like the cards: a texture given back + // at once could still be sampled by a frame the GPU has not finished. + _textures.forEach( + drawing?.releaseTextureAfterFrame ?? device.releaseTexture, + ); + _cards.clear(); + _textures.clear(); + _materials.clear(); + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/bridge_space.dart b/packages/flame_flutter3d/lib/src/transform/bridge_space.dart new file mode 100644 index 00000000000..c4eb7d2fddc --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/bridge_space.dart @@ -0,0 +1,57 @@ +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_sim/flutter3d_sim.dart' show Portable; +import 'package:vector_math/vector_math.dart' show Quaternion, Vector3; + +/// Where a Flame point is in the scene and which way a Flame angle faces +/// there: what an `Object3dComponent` writes its node through. +/// +/// A `BridgePlane` is the flat one. [CurvilinearSpace] bends Flame's world +/// along a road, so a racing game can keep its cars in Flame's straight +/// coordinates (across the road, and along it) while the road winds. +abstract interface class BridgeSpace { + /// The scene point for Flame's ([x], [y]), [lift] metres up, into [out]. + void place(double x, double y, double lift, Vector3 out); + + /// The rotation a Flame [angle] draws with at ([x], [y]), into [out]. + void turn(double x, double y, double angle, Quaternion out); +} + +/// Flame's world laid along [path]: Flame's `x` is metres right of the +/// road's middle, Flame's `-y` is metres along it (so up the screen is +/// forward, as on a ground plane), and a Flame angle turns from the road's +/// own heading. +/// +/// **What Enduro's road needs.** Its cars live on a straight strip in +/// Flame, where overtaking is a change of `x` and speed a change of `y`, +/// and are drawn on a track that bends. Keeping them in Flame's straight +/// coordinates keeps Flame's hitboxes meaning "side by side on the road", +/// however the road turns under them. +final class CurvilinearSpace implements BridgeSpace { + CurvilinearSpace(this.path); + + final OpenPath path; + + final Vector3 _right = Vector3.zero(); + final Vector3 _ahead = Vector3.zero(); + + @override + void place(double x, double y, double lift, Vector3 out) { + final s = -y; + path + ..pointAt(s, out) + ..rightAt(s, _right); + out + ..addScaled(_right, x) + ..addScaled(path.up, lift); + } + + @override + void turn(double x, double y, double angle, Quaternion out) { + path.tangentAt(-y, _ahead); + // The road's heading as a turn about up from -Z, then Flame's own angle + // on top of it, clockwise on screen as on a ground plane. + final heading = Portable.atan2(-_ahead.x, -_ahead.z); + final phi = heading - angle; + out.setAxisAngle(path.up, phi); + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/bridged3d.dart b/packages/flame_flutter3d/lib/src/transform/bridged3d.dart new file mode 100644 index 00000000000..f694bdc0d8f --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/bridged3d.dart @@ -0,0 +1,70 @@ +import 'package:flame/components.dart' show Component; +import 'package:flame/effects.dart' show ComponentEffect; +import 'package:flame_flutter3d/src/transform/bridge_space.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:vector_math/vector_math.dart' show Aabb3, Vector4; + +/// What a bridged component is to the parts of the bridge that find it or +/// draw round it: a tap, a debug outline of its hitboxes. Both +/// `Object3dComponent` and `InstancedObject3dComponent` are one. +/// +/// **An instance is a bridged component too.** Taps and hitbox outlines +/// asked for an `Object3dComponent`, and an invader drawn as one instance of +/// fifty-five could neither be tapped nor have its hitbox seen. +abstract interface class Bridged3d implements Drawn3d { + /// The plane its Flame point is on. + BridgePlane get plane; + + /// Metres off [plane] along its normal. + double get elevation; + + /// Where its Flame point is placed instead of flat on [plane], if bent. + BridgeSpace? get space; + + /// The linear colour what it draws is multiplied by: what a + /// [TintEffect] moves. + @override + Vector4 get tint; +} + +/// What a tap asks of anything drawn in the scene: where it is drawn, and +/// its colour. Every [Bridged3d] is one, and so is a `Node3dComponent`, +/// which stands in full 3D rather than on a plane. +abstract interface class Drawn3d { + /// The box in the scene round what it draws, or null when it draws + /// nothing: what a tap is tested against. + Aabb3? get drawnBounds3d; + + /// The linear colour what it draws is multiplied by. + Vector4 get tint; +} + +/// Moves a bridged component's [Bridged3d.tint] to a colour, as Flame's +/// `ColorEffect` moves a sprite's paint: a hit flash, a wreck charring. +/// +/// **Flame's own `ColorEffect` cannot reach it.** That effect wants a +/// component with a paint, and a bridged component draws in 3D, with none; +/// a hit flash was a timer and two assignments in the game. This moves the +/// tint from wherever it is when the effect starts to `colour`, on any +/// `EffectController`: alternating for a flash, one way for a fade. +class TintEffect extends ComponentEffect { + TintEffect(Vector4 colour, super.controller, {super.onComplete, super.key}) + : _to = colour.clone(); + + final Vector4 _to; + final Vector4 _from = Vector4.zero(); + + Vector4 get _tint => switch (target) { + final Drawn3d drawn => drawn.tint, + _ => throw UnsupportedError('A TintEffect is for a bridged component.'), + }; + + @override + void onStart() { + super.onStart(); + _from.setFrom(_tint); + } + + @override + void apply(double progress) => Vector4.mix(_from, _to, progress, _tint); +} diff --git a/packages/flame_flutter3d/lib/src/transform/flame_pose.dart b/packages/flame_flutter3d/lib/src/transform/flame_pose.dart new file mode 100644 index 00000000000..8fe0d7e4427 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/flame_pose.dart @@ -0,0 +1,93 @@ +import 'package:flame/components.dart'; + +/// Where Flame draws a component, as the bridge writes it into the scene: a +/// point, a turn and a scale whose signs say which way it is mirrored. +/// +/// **Flame's own absolute angle is not this turn.** `absoluteAngle` is +/// reflected for a flipped component, the angle it appears at on screen, +/// and written with the signed absolute scale beside it the mirror was +/// applied twice: a flipped ship nested under anything turned the opposite +/// way to the same ship at the top of the tree. Here the chain is folded +/// the way Flame's matrices compose it, a parent's mirror reversing the +/// turns under it, so a component and its pose agree whatever it hangs +/// from. A scale that differs between the axes under a turned parent +/// shears in Flame, and a node cannot; it keeps the axes' scales and loses +/// the shear. +/// +/// Mutable, and read into, because it is read for every bridged component +/// every frame. +final class FlamePose { + double x = 0.0; + double y = 0.0; + double turn = 0.0; + double scaleX = 1.0; + double scaleY = 1.0; + + /// Reads [component]'s pose. One with no positioned ancestor is read from + /// its own fields, which cost nothing; Flame's absolute ones are made + /// afresh on every read. + void readFrom(PositionComponent component) { + if (!hasPlacedAncestor(component)) { + x = component.position.x; + y = component.position.y; + turn = component.angle; + scaleX = component.scale.x; + scaleY = component.scale.y; + return; + } + final at = component.absolutePosition; + x = at.x; + y = at.y; + readTurnOf(component); + } + + /// Reads only [turn] and the scales of [component] and everything above + /// it: the frame a child's own angle and scale are in, one level down. + void readTurnOf(Component component) { + turn = 0.0; + scaleX = 1.0; + scaleY = 1.0; + _fold(component); + } + + /// Whether what is drawn under this pose is mirrored, and so has its + /// turns reversed. + bool get mirrored => (scaleX < 0.0) != (scaleY < 0.0); + + void _fold(Component? at) { + if (at == null) { + return; + } + _fold(at.parent); + if (at is! PositionComponent) { + return; + } + turn += mirrored ? -at.angle : at.angle; + scaleX *= at.scale.x; + scaleY *= at.scale.y; + } +} + +/// Whether anything above [component] is positioned: then where it is +/// drawn is not its own [PositionComponent.position]. Asked of the whole +/// chain, as Flame's `absolutePositionOf` walks it, not of the parent +/// alone: a plain `Component` between a frog and its log hid the log. +bool hasPlacedAncestor(Component component) { + for (var at = component.parent; at != null; at = at.parent) { + if (at is PositionComponent) { + return true; + } + } + return false; +} + +/// The nearest positioned component above [component], whose space its +/// [PositionComponent.position] is in; null when there is none. +PositionComponent? placedAncestor(Component component) { + for (var at = component.parent; at != null; at = at.parent) { + if (at is PositionComponent) { + return at; + } + } + return null; +} diff --git a/packages/flame_flutter3d/lib/src/transform/instanced_object3d_component.dart b/packages/flame_flutter3d/lib/src/transform/instanced_object3d_component.dart new file mode 100644 index 00000000000..a89a72c5e04 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/instanced_object3d_component.dart @@ -0,0 +1,249 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart' show OpacityProvider; +import 'package:flame_flutter3d/src/transform/bridge_space.dart'; +import 'package:flame_flutter3d/src/transform/bridged3d.dart'; +import 'package:flame_flutter3d/src/transform/flame_pose.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show shownInFlame, Object3dComponent; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A Flame [PositionComponent] drawn as one instance of a shared +/// [InstancedMeshNode]: a shot, a spark, an invader in a row of fifty-five. +/// +/// **What [Object3dComponent] is for many small things of one shape.** Each +/// [Object3dComponent] is a node and a draw; a hundred shots in the air were +/// a hundred draws of one rod. This one takes a slot in [batch] when it is +/// mounted, writes its Flame transform into the slot every frame the way +/// [Object3dComponent] writes a node's, and gives the slot back when it is +/// removed. The batch is one draw however many are in the air. +/// +/// Flowing one way only, Flame to the scene: nothing but this component +/// writes the slot, so there is nothing to read back. +/// +/// **[batch] sits at the scene's origin, unturned.** An instance's transform +/// is in the batch node's space, and this writes the scene position there +/// as it is. Add the batch to the scene's root and leave it. +/// +/// **Hidden is collapsed.** An instance has no visibility of its own, so a +/// component Flame hides ([HasVisibility.isVisible]) writes a transform of +/// zeros into its slot, which draws nothing. Removed, it gives the slot up +/// at once rather than on Flame's next lifecycle pass, so it is not drawn +/// a frame after the game let it go. +/// +/// **A tint and an opacity of its own**, written into the slot's colour: a +/// hit flash on one invader of fifty-five. The colour multiplies the mesh's +/// vertex colour. The batch is one draw with one material, so [opacity] +/// fades an instance only when that material blends; over an opaque one it +/// changes nothing. +class InstancedObject3dComponent extends PositionComponent + with CustomTraversal, HasVisibility + implements OpacityProvider, Bridged3d { + InstancedObject3dComponent({ + required this.batch, + required this.plane, + this.elevation = 0.0, + this.color, + this.space, + super.position, + super.size, + super.anchor, + super.angle, + super.scale, + super.children, + super.priority, + super.key, + }); + + /// The batch this component takes a slot in. + final InstancedMeshNode batch; + + /// The 2D↔3D axis mapping the transform is written through. + @override + final BridgePlane plane; + + /// Metres off [plane] along its normal, as [Object3dComponent.elevation]. + @override + double elevation; + + /// Where Flame's point is placed and turned instead of flat on [plane], + /// as [Object3dComponent.space]: cars of one shape down a bending road. + @override + final BridgeSpace? space; + + /// The box in the scene round this instance: the batch's mesh where the + /// slot puts it. Null while it holds no slot or is hidden. + @override + Aabb3? get drawnBounds3d { + if (_slot == null || _writtenHidden) { + return null; + } + return batch.mesh.bounds.transformed( + batch.worldMatrix.multiplied(_transform), + _bounds, + ); + } + + final Aabb3 _bounds = Aabb3(); + + /// The instance's colour when it is made, white when null; [tint] starts + /// from it. + final Vector4? color; + + /// The linear colour the instance is multiplied by, read every frame. + @override + late final Vector4 tint = color?.clone() ?? Vector4.all(1.0); + + /// How opaque the instance is: what Flame's `OpacityEffect` moves. See the + /// class doc for when it shows. + @override + double opacity = 1.0; + + final Vector4 _written = Vector4.all(double.nan); + final Vector4 _colour = Vector4.zero(); + + void _writeColour() { + final slot = _slot; + if (slot == null) { + return; + } + _colour.setValues(tint.x, tint.y, tint.z, tint.w * opacity); + if (_colour == _written) { + return; + } + _written.setFrom(_colour); + slot.setColor(_colour); + } + + InstanceHandle? _slot; + + /// The slot this component draws through, while it is mounted. + InstanceHandle? get slot => _slot; + + final Matrix4 _transform = Matrix4.zero(); + final Vector3 _scale = Vector3.zero(); + + /// Where this component is in the scene: its absolute Flame position on + /// [plane], lifted by [elevation]. + Vector3 get scenePosition { + final at = absolutePosition; + final bent = space; + if (bent == null) { + return plane.to3d(at, at: plane.constant + elevation); + } + final out = Vector3.zero(); + bent.place(at.x, at.y, elevation, out); + return out; + } + + @override + void onMount() { + super.onMount(); + _slot = batch.acquire(color: color); + _writtenX = double.nan; + _writtenHidden = false; + _written.setValues(double.nan, double.nan, double.nan, double.nan); + _write(); + _writeColour(); + } + + @override + void removeFromParent() { + _giveBack(); + super.removeFromParent(); + } + + @override + void onRemove() { + _giveBack(); + super.onRemove(); + } + + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + _write(); + _writeColour(); + } + + void _giveBack() { + final slot = _slot; + _slot = null; + if (slot != null && slot.live) { + batch.release(slot); + } + } + + /// Writes Flame's transform into the slot, and only when it moved or was + /// hidden or shown: a write marks the whole batch changed, bounds and + /// shadows with it, as a node's does. See `Object3dComponent`. + void _write() { + final slot = _slot; + if (slot == null) { + return; + } + final shown = shownInFlame(this); + if (!shown) { + if (_writtenHidden) { + return; + } + _writtenHidden = true; + _writtenX = double.nan; + slot.setTransform(_transform..setZero()); + return; + } + final pose = _pose..readFrom(this); + final x = pose.x; + final y = pose.y; + final turn = pose.turn; + final sx = pose.scaleX; + final sy = pose.scaleY; + if (!_writtenHidden && + x == _writtenX && + y == _writtenY && + turn == _writtenAngle && + sx == _writtenScaleX && + sy == _writtenScaleY && + elevation == _writtenElevation) { + return; + } + _writtenHidden = false; + _writtenX = x; + _writtenY = y; + _writtenAngle = turn; + _writtenScaleX = sx; + _writtenScaleY = sy; + _writtenElevation = elevation; + + final across = (sx.abs() + sy.abs()) / 2.0; + switch (plane.axis) { + case PlaneAxis.y: + _scale.setValues(sx, across, sy); + case PlaneAxis.z: + _scale.setValues(sx, sy, across); + } + final bent = space; + if (bent == null) { + plane + ..to3dInto(x, y, _place, at: plane.constant + elevation) + ..rotationInto(turn, _turn); + } else { + bent + ..place(x, y, elevation, _place) + ..turn(x, y, turn, _turn); + } + _transform.setFromTranslationRotationScale(_place, _turn, _scale); + slot.setTransform(_transform); + } + + final FlamePose _pose = FlamePose(); + final Vector3 _place = Vector3.zero(); + final Quaternion _turn = Quaternion.identity(); + bool _writtenHidden = false; + double _writtenX = double.nan; + double _writtenY = double.nan; + double _writtenAngle = double.nan; + double _writtenScaleX = double.nan; + double _writtenScaleY = double.nan; + double _writtenElevation = double.nan; +} diff --git a/packages/flame_flutter3d/lib/src/transform/node3d_component.dart b/packages/flame_flutter3d/lib/src/transform/node3d_component.dart new file mode 100644 index 00000000000..b943e5b9c59 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/node3d_component.dart @@ -0,0 +1,259 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_flutter3d/src/transform/bridged3d.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show shownInFlame; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A Flame component that stands in full 3D: a place in the scene, a turn +/// about any axis and a scale on each, with no plane under it. A starfighter +/// in Star Raiders, a tank on the plain of Battlezone seen from its turret, a +/// ship of Solaris. +/// +/// **Flame's tree, the scene's space.** Everything else in this bridge is a +/// Flame `PositionComponent` on a plane, two numbers made three. A game that +/// flies needs all three and a turn about any axis, and Flame has no such +/// component; this is one, kept a Flame component so the game's logic, its +/// timers, its collision of its own and its effects are Flame's. +/// [position3], [rotation3] and [scale3] are written into [node] when they +/// change, and only then. +/// +/// **Nested as the scene nests.** Under another [Node3dComponent], [node] +/// hangs under the parent's node, so a turret turns with its tank and a +/// cockpit's camera, added to a ship's [node], flies with it. Anywhere +/// else, it is added to [scene]'s root. +/// +/// **Moved by Flame's effects.** [Move3dEffect], [Rotate3dEffect] and +/// [Scale3dEffect] take any `EffectController`: eased, repeated, +/// alternating, in sequence. [TintEffect] and `OpacityEffect` colour and +/// fade it, and `Tap3dCallbacks` hear a tap on it. +class Node3dComponent extends Component + with CustomTraversal, HasVisibility + implements OpacityProvider, Drawn3d { + Node3dComponent({ + required this.node, + required this.scene, + Vector3? position, + Quaternion? rotation, + Vector3? scale, + super.children, + super.priority, + super.key, + }) : position3 = position?.clone() ?? Vector3.zero(), + rotation3 = rotation?.clone() ?? Quaternion.identity(), + scale3 = scale?.clone() ?? Vector3.all(1.0); + + /// What is drawn. + final SceneNode node; + + /// The scene [node] is added to when it has no 3D parent. + final Scene scene; + + /// Where it is, in its parent's space: the parent's node's, or the + /// scene's. + final Vector3 position3; + + /// How it is turned, in its parent's space. + final Quaternion rotation3; + + /// How it is scaled, along each of its own axes. + final Vector3 scale3; + + @override + double opacity = 1.0; + + @override + final Vector4 tint = Vector4.all(1.0); + + final Vector3 _writtenPosition = Vector3.all(double.nan); + final Quaternion _writtenRotation = Quaternion( + double.nan, + double.nan, + double.nan, + double.nan, + ); + final Vector3 _writtenScale = Vector3.all(double.nan); + bool? _visibleWritten; + bool _tintWritten = false; + + @override + Aabb3? get drawnBounds3d => node.subtreeBounds; + + @override + void onMount() { + super.onMount(); + final above = _parentNode(); + if (above != null) { + above.add(node); + } else if (node.parent == null) { + scene.add(node); + } + _visibleWritten = null; + _write(); + } + + SceneNode? _parentNode() { + for (var at = parent; at != null; at = at.parent) { + if (at is Node3dComponent) { + return at.node; + } + } + return null; + } + + @override + void onRemove() { + node.removeFromParent(); + super.onRemove(); + } + + /// After the effects under it have moved it this frame. + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + _write(); + } + + void _write() { + if (position3 != _writtenPosition) { + _writtenPosition.setFrom(position3); + node.setPositionFrom(position3); + } + final r = rotation3; + final w = _writtenRotation; + if (r.x != w.x || r.y != w.y || r.z != w.z || r.w != w.w) { + w.setFrom(r); + node.setRotation(r); + } + if (scale3 != _writtenScale) { + _writtenScale.setFrom(scale3); + node.setScale(scale3.x, scale3.y, scale3.z); + } + final shown = shownInFlame(this); + if (_visibleWritten != shown) { + node.visible = shown; + _visibleWritten = shown; + } + final alpha = tint.w * opacity; + final plain = + tint.x == 1.0 && tint.y == 1.0 && tint.z == 1.0 && alpha == 1.0; + if (plain && !_tintWritten) { + return; + } + _tintWritten = !plain; + _paint(node, alpha); + } + + /// Its own meshes; a [Node3dComponent] under it paints its own. + void _paint(SceneNode at, double alpha) { + if (at is MeshNode) { + at.tint.setValues(tint.x, tint.y, tint.z, alpha); + } + for (final child in at.children) { + if (_isOwnNode(child)) { + continue; + } + _paint(child, alpha); + } + } + + bool _isOwnNode(SceneNode child) => children.whereType().any( + (c) => identical(c.node, child), + ); +} + +/// Moves a [Node3dComponent] by an offset in its parent's space, or to a +/// place, on any `EffectController`: Flame's `MoveEffect`, in three +/// dimensions. +class Move3dEffect extends ComponentEffect { + /// Moves it by [offset]. + Move3dEffect.by( + Vector3 offset, + super.controller, { + super.onComplete, + super.key, + }) : _offset = offset.clone(), + _to = null; + + /// Moves it to [destination], from wherever it is when the effect starts. + Move3dEffect.to( + Vector3 destination, + super.controller, { + super.onComplete, + super.key, + }) : _offset = Vector3.zero(), + _to = destination.clone(); + + final Vector3 _offset; + final Vector3? _to; + + @override + void onStart() { + super.onStart(); + final to = _to; + if (to != null) { + _offset.setFrom(to - target.position3); + } + } + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + target.position3.addScaled(_offset, dProgress); + } +} + +/// Turns a [Node3dComponent] by [angle] radians about [axis], in its own +/// frame, on any `EffectController`: Flame's `RotateEffect`, about any +/// axis. +class Rotate3dEffect extends ComponentEffect { + Rotate3dEffect.by( + Vector3 axis, + this.angle, + super.controller, { + super.onComplete, + super.key, + }) : axis = axis.normalized(); + + /// About what, in its own frame. + final Vector3 axis; + + /// How far, in radians. + final double angle; + + final Quaternion _step = Quaternion.identity(); + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + _step.setAxisAngle(axis, angle * dProgress); + target.rotation3.setFrom(target.rotation3 * _step); + } +} + +/// Scales a [Node3dComponent] to a scale, from wherever it is when the +/// effect starts, on any `EffectController`: Flame's `ScaleEffect`, on +/// each axis. +class Scale3dEffect extends ComponentEffect { + Scale3dEffect.to( + Vector3 scale, + super.controller, { + super.onComplete, + super.key, + }) : _to = scale.clone(); + + final Vector3 _to; + final Vector3 _offset = Vector3.zero(); + + @override + void onStart() { + super.onStart(); + _offset.setFrom(_to - target.scale3); + } + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + target.scale3.addScaled(_offset, dProgress); + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/object3d_component.dart b/packages/flame_flutter3d/lib/src/transform/object3d_component.dart new file mode 100644 index 00000000000..bce310d1d5b --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/object3d_component.dart @@ -0,0 +1,463 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart' + show OpacityProvider, ReadOnlyAngleProvider, ReadOnlyPositionProvider; +import 'package:flame_flutter3d/flame_flutter3d.dart' + show RigidBodyComponent, ActorComponent; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/transform/bridge_space.dart'; +import 'package:flame_flutter3d/src/transform/bridged3d.dart'; +import 'package:flame_flutter3d/src/transform/flame_pose.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// Which side of an [Object3dComponent] writes a frame's transform into the +/// other. +/// +/// Nothing infers a direction from which value "changed more recently" — +/// two systems can each believe the other is the one reading, and a +/// transform that no caller ever explicitly wrote into would still look +/// changed to whichever side polled it first. A direction chosen once, at +/// construction, is one field instead of a heuristic. +enum SyncDirection { + /// This component's flutter3d [SceneNode] is authoritative; its position + /// and rotation are copied onto the Flame side every frame. What every + /// existing flutter3d system already owns — a rigid body, an actor — is + /// scene-authoritative, so [RigidBodyComponent] and [ActorComponent] both + /// default to this. + sceneToFlame, + + /// This component's Flame [PositionComponent] is authoritative; its + /// position and angle are copied onto the flutter3d [SceneNode] every + /// frame — a Flame-driven prop that should also draw as a 3D billboard, + /// say. + flameToScene, +} + +/// A Flame [PositionComponent] and a flutter3d [SceneNode] kept at the same +/// place, on one [BridgePlane], one [direction] deciding who writes. +/// +/// **Size and anchor are Flame's, and usually wanted.** Nothing on the 3D side +/// reads them, but a `RectangleHitbox()` fills its parent's [size], and the +/// [anchor] decides whether [position] (the point written into the scene) is +/// the component's centre or its corner. A bridged component that collides +/// passes `anchor: Anchor.center` and the size of what it draws. +/// +/// **Lifecycle follows Flame's.** [onMount] adds [node] to [scene]; [onRemove] +/// calls `node.removeFromParent()`. A [SceneNode] never outlives the +/// component that owns it, and never needs a caller to remember to detach +/// it by hand — the same guarantee `CameraNode.onAttachedToScene` already +/// gives a [Scene]'s own registries. [removeFromParent] hides [node] at +/// once: Flame takes the component out of its tree on the next lifecycle +/// pass, and a node that stayed visible until then was drawn one frame +/// after the game had let it go. +/// +/// **Where it is, not where it is relative to its parent.** The transform +/// written into the scene is Flame's absolute one, so a component nested +/// under another (a frog riding a log) lands where Flame draws it. [node] +/// itself stays wherever it was added, normally the scene's root. +/// +/// **Flowing Flame to the scene, it syncs after its children.** A Flame +/// effect is a child of the component it moves, and effects update after +/// their parent's own [update]; syncing in [update] put the 3D side a frame +/// behind every `MoveEffect` and `RotateEffect`. [updateSubtree] runs the sync +/// once the whole subtree has moved. +/// +/// **The rest of Flame's transform crosses too.** [elevation] lifts the +/// point off the plane along its normal. Flame's absolute scale, its own +/// times its ancestors', scales [node], the plane's two axes from its `x` +/// and `y` and the normal from their mean. Flame's visibility is written +/// into `node.visible` whenever it changes, and only then, so code that +/// blinks a node by hand keeps working: shown when this component and every +/// ancestor with [HasVisibility] is, as Flame draws it, since a hidden +/// parent hides its children. +/// +/// **[visual] is the bridge's to create and the game's to turn.** The +/// bridge writes [node]'s rotation every frame, so a model turned to face +/// its way, banked into a turn or tilted as it sinks has to hang from a +/// node below it. [visual] is that node, made the first time it is asked +/// for; nothing here writes its transform. +/// +/// **Opacity and a tint cross as well.** [opacity], which Flame's +/// `OpacityEffect` drives, fades every mesh under [node], and [tint] +/// colours them, through each mesh's own `MeshNode.tint`: a hit flash or a +/// wreck fading out, over a material a hundred craft share. +class Object3dComponent extends PositionComponent + with CustomTraversal, HasVisibility + implements OpacityProvider, Bridged3d { + Object3dComponent({ + required this.node, + required this.scene, + required this.plane, + this.direction = SyncDirection.sceneToFlame, + this.elevation = 0.0, + this.owns = const [], + this.space, + this.follows, + super.position, + super.size, + super.anchor, + super.angle, + super.scale, + super.children, + super.priority, + super.key, + }); + + /// Where Flame's point is placed and turned in the scene, when not flat on + /// [plane]: a [CurvilinearSpace] bends it along a road. Null places it on + /// [plane]. Only the write from Flame to the scene goes through it: a + /// component read back from the scene is read flat off [plane]. + @override + final BridgeSpace? space; + + /// Something of Flame's this stands where it stands, and turns as it + /// turns when it has an angle: a `flame_forge2d` `BodyComponent`, whose + /// place its body decides, is both. Flowing Flame to the scene, its + /// position and angle are taken every frame before they are written. + /// + /// **Flame's own physics, drawn in 3D.** A body of `flame_forge2d` is not + /// a `PositionComponent`, so nothing of this bridge could be hung under + /// it, and a pinball table whose flippers and ball its solver moves could + /// not be drawn here. Any of Flame's position providers will do. + final ReadOnlyPositionProvider? follows; + + void _follow() { + final target = follows; + if (target == null) { + return; + } + position.setFrom(target.position); + if (target is ReadOnlyAngleProvider) { + angle = (target as ReadOnlyAngleProvider).angle; + } + } + + /// The box round [node] and everything under it. + @override + Aabb3? get drawnBounds3d => node.subtreeBounds; + + /// Meshes this component made for itself and lets go of when it is + /// removed: a bridge's span, a wreck's hull built for the moment. + /// + /// **Let go after the frames in flight**, through the renderer of the + /// `HasFlutter3d` game it is in, since a frame already sent may still be + /// drawing them; at once when that game has no renderer, as in a test. A + /// game without `HasFlutter3d` has no device here to give them back to, + /// and keeps them. A component that is pooled and added again must not + /// own anything: removal is the end of what it owns. + final List owns; + + HasFlutter3d? _host; + + /// The flutter3d node this component is bridged to. + final SceneNode node; + + /// The scene [node] is added to on mount and removed from on unmount. + final Scene scene; + + /// The 2D↔3D axis mapping this component reads and writes through. + @override + final BridgePlane plane; + + /// Which side is authoritative each frame. See [SyncDirection]. + final SyncDirection direction; + + /// Metres off [plane] along its normal: a flying craft's height over a + /// ground plane, a jump's arc, a tanker settling under the water. Read + /// every frame, so an effect or the game can move it. + @override + double elevation; + + SceneNode? _visual; + bool? _visibleWritten; + + /// How opaque every mesh under [node] is drawn, from 0 to 1. What Flame's + /// `OpacityEffect` moves. + @override + double opacity = 1.0; + + /// A linear colour every mesh under [node] is multiplied by; its alpha + /// multiplies [opacity]. White leaves them as their materials say. + @override + final Vector4 tint = Vector4.all(1.0); + + bool _tintWritten = false; + + /// A node under [node] for what is drawn, which the bridge never turns. + /// Made, and added to [node], the first time it is read. + SceneNode get visual { + final made = _visual; + if (made != null) { + return made; + } + final visual = SceneNode(name: '${node.name ?? 'object'} visual'); + node.add(visual); + return _visual = visual; + } + + /// Where this component is in the scene: its absolute Flame position on + /// [plane], lifted by [elevation]. For placing something at it, a blast + /// where a target went down, say. + Vector3 get scenePosition { + final bent = space; + if (bent == null) { + return plane.to3d(absolutePosition, at: plane.constant + elevation); + } + final at = absolutePosition; + final out = Vector3.zero(); + bent.place(at.x, at.y, elevation, out); + return out; + } + + @override + void onMount() { + super.onMount(); + final game = findGame(); + if (game is HasFlutter3d) { + _host = game; + } + if (node.parent == null) { + scene.add(node); + } + _visibleWritten = null; + } + + /// **Moved is not gone.** Flame moves a component to a new parent by + /// removing it and mounting it again at once, and [owns] let go of here + /// was a moved bridge drawing meshes already given back. So they are let + /// go of a moment later, and only if the component is by then in no tree + /// and on its way to none. + @override + void onRemove() { + node.removeFromParent(); + if (owns.isNotEmpty) { + scheduleMicrotask(() { + if (!isMounted && parent == null) { + _letGo(); + } + }); + } + super.onRemove(); + } + + void _letGo() { + final host = _host; + if (host == null || !host.has3d) { + return; + } + final drawing = host.renderer; + for (final mesh in owns) { + if (drawing != null) { + drawing.releaseMeshAfterFrame(mesh); + } else { + host.device + ..releaseGeometry(mesh.vertices) + ..releaseGeometry(mesh.indices); + } + } + } + + @override + void removeFromParent() { + node.visible = false; + _visibleWritten = false; + super.removeFromParent(); + } + + /// Reads the scene side first, so this component's children see where the + /// body is this frame. Flowing the other way it writes the scene here too, + /// for a caller that drives a component by calling [update] itself, and + /// again in [updateSubtree] once the effects under it have moved it. + @override + void update(double dt) { + super.update(dt); + switch (direction) { + case SyncDirection.sceneToFlame: + _readScene(); + case SyncDirection.flameToScene: + _follow(); + _writeScene(); + } + } + + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + if (direction == SyncDirection.flameToScene) { + _follow(); + _writeScene(); + } + _writeTint(); + if (isRemoving) { + node.visible = false; + } else { + final shown = shownInFlame(this); + if (_visibleWritten != shown) { + node.visible = shown; + _visibleWritten = shown; + } + } + } + + /// Writes [tint] and [opacity] into every mesh under [node] while either + /// is not plain, so a model dressed onto the node later takes it too, and + /// once more when they come back to plain. + void _writeTint() { + final alpha = tint.w * opacity; + final plain = + tint.x == 1.0 && tint.y == 1.0 && tint.z == 1.0 && alpha == 1.0; + if (plain && !_tintWritten) { + return; + } + _tintWritten = !plain; + _paint(node, alpha); + } + + void _paint(SceneNode at, double alpha) { + if (at is MeshNode) { + at.tint.setValues(tint.x, tint.y, tint.z, alpha); + } + for (final child in at.children) { + _paint(child, alpha); + } + } + + /// Writes Flame's transform into [node], and only when it moved. + /// + /// **Unchanged is not written.** A node's setters mark it changed whatever + /// they are given, and the engine reads that mark to decide whether its + /// shadow cascades and its tree of bounds are still good. A bridged prop + /// that never moved rewrote its place every frame, and one still tanker on + /// the river had every shadow redrawn every frame. So the transform is + /// compared with the one last written, and a component nested in nothing + /// reads its own fields rather than Flame's absolute ones, which are made + /// afresh on every read. + void _writeScene() { + final pose = _pose..readFrom(this); + final x = pose.x; + final y = pose.y; + final turn = pose.turn; + final sx = pose.scaleX; + final sy = pose.scaleY; + if (x == _writtenX && + y == _writtenY && + turn == _writtenAngle && + sx == _writtenScaleX && + sy == _writtenScaleY && + elevation == _writtenElevation) { + return; + } + _writtenX = x; + _writtenY = y; + _writtenAngle = turn; + _writtenScaleX = sx; + _writtenScaleY = sy; + _writtenElevation = elevation; + + final bent = space; + if (bent == null) { + plane.to3dInto(x, y, _place, at: plane.constant + elevation); + plane.rotationInto(turn, _turn); + } else { + bent + ..place(x, y, elevation, _place) + ..turn(x, y, turn, _turn); + } + node + ..setPositionFrom(_place) + ..setRotation(_turn); + final across = (sx.abs() + sy.abs()) / 2.0; + switch (plane.axis) { + case PlaneAxis.y: + node.setScale(sx, across, sy); + case PlaneAxis.z: + node.setScale(sx, sy, across); + } + } + + final FlamePose _pose = FlamePose(); + final Vector3 _place = Vector3.zero(); + final Quaternion _turn = Quaternion.identity(); + double _writtenX = double.nan; + double _writtenY = double.nan; + double _writtenAngle = double.nan; + double _writtenScaleX = double.nan; + double _writtenScaleY = double.nan; + double _writtenElevation = double.nan; + + /// Moves [node] to [at], and only if it is not there already: for a + /// subclass that carries a body's place onto the node every frame. A + /// body at rest was written every frame all the same, and a written node + /// is a changed node, whose shadow cascades are drawn again. + void placeNode(Vector3 at) { + final now = node.readPosition(_nodeAt); + if (now.x == at.x && now.y == at.y && now.z == at.z) { + return; + } + node.setPositionFrom(at); + } + + /// Turns [node] to [yaw] radians about the world's up, and only if it is + /// not turned so already; see [placeNode]. + void turnNodeTo(double yaw) { + if (yaw == _placedYaw) { + return; + } + _placedYaw = yaw; + node.setRotation(_yawTurn..setAxisAngle(_up, yaw)); + } + + final Vector3 _nodeAt = Vector3.zero(); + double _placedYaw = double.nan; + final Quaternion _yawTurn = Quaternion.identity(); + static Vector3 get _up => Vector3(0.0, 1.0, 0.0); + + /// Moves what this component's place is read from by [by], in the scene: + /// [node], and in a subclass the body under it, without stopping it. How + /// a `WrapSpace` carries a component placed from the scene side across + /// its seam; wrapping Flame's position alone was undone by the next read. + void shiftScene(Vector3 by) { + node.setPositionFrom(node.readPosition(_nodeAt)..add(by)); + } + + /// Forgets what was last written, so the next write happens whether or + /// not Flame's side moved: for a caller that moved [node] itself and + /// wants Flame's place put back. + void rewriteScene() => _writtenX = double.nan; + + /// The node's place, brought into the space of the nearest positioned + /// component above this one, when there is one; under a mirrored one the + /// turn is reversed, as it is on the way out. + void _readScene() { + final world = plane.to2d(node.readPosition()); + final worldAngle = plane.angleFor(node.readRotation()); + final holder = placedAncestor(this); + if (holder != null) { + final above = _pose..readTurnOf(holder); + final local = worldAngle - above.turn; + position = holder.absoluteToLocal(world); + angle = above.mirrored ? -local : local; + } else { + position = world; + angle = worldAngle; + } + } +} + +/// Whether Flame draws [component]: it is visible, and so is every ancestor +/// that can be hidden. A hidden parent does not render its children, and +/// the scene node of a child is not under its parent's node, so the bridge +/// has to ask the whole chain. +bool shownInFlame(HasVisibility component) { + if (!component.isVisible) { + return false; + } + for (final ancestor in component.ancestors()) { + if (ancestor is HasVisibility && !ancestor.isVisible) { + return false; + } + } + return true; +} diff --git a/packages/flame_flutter3d/lib/src/transform/plane.dart b/packages/flame_flutter3d/lib/src/transform/plane.dart new file mode 100644 index 00000000000..cf9689ff74f --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/plane.dart @@ -0,0 +1,202 @@ +/// The one place a Flame `Vector2` and a flutter3d `Vector3` are the same +/// point, stated instead of assumed. +/// +/// **Why this exists at all.** Every bridged transform — a Flame component's +/// position, a rigid body's, an actor's — has to cross from Flame's flat +/// world into flutter3d's spatial one and back, and there are exactly two +/// honest ways to do that: pick an axis convention once, in one class every +/// bridge shares, or let each bridge invent its own and drift. This is the +/// first. A side-scroller wants Flame's Y to become flutter3d's own Y (depth +/// on Z); a top-down game wants Flame's Y to become flutter3d's Z (a ground +/// plane at a fixed height). Both are [BridgePlane]s; neither is hardcoded +/// into a component. +/// +/// **One `Vector2`, not two.** Flame re-exports `package:vector_math`'s own +/// `Vector2` rather than defining its own (`package:flame/src/extensions/ +/// vector2.dart`), so a point crossing this bridge is never copied between +/// two unrelated classes — only ever reshaped between two and three +/// components. +/// +/// **Named `BridgePlane`, not `Plane`.** `package:vector_math` already +/// exports a geometric `Plane` (a half-space for frustum/collision tests), +/// and this package depends on it transitively through flutter3d itself — +/// the collision would be silent until a caller's own import order broke, +/// which is worse than a name one character longer. +library; + +import 'package:flame_flutter3d/src/transform/bridge_space.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show Portable; +import 'package:vector_math/vector_math.dart'; + +/// Which flutter3d axis a [BridgePlane] holds constant. +enum PlaneAxis { + /// A ground plane: Y is constant, Flame's `y` becomes flutter3d's Z. + y, + + /// A backdrop: Z is constant, Flame's `y` becomes flutter3d's Y. + z, +} + +/// Maps a Flame [Vector2] to and from a flutter3d [Vector3], and a Flame +/// rotation angle to and from a flutter3d [Quaternion]. +/// +/// A plane is defined by which flutter3d axis stays fixed at [constant] — +/// [PlaneAxis.y] for a ground plane's height, [PlaneAxis.z] for a backdrop's +/// depth — and Flame's `x`/`y` become whichever two flutter3d axes are left. +final class BridgePlane implements BridgeSpace { + const BridgePlane({ + required this.axis, + required this.constant, + this.flipY = false, + }); + + /// A ground plane at [height]: Flame `(x, y)` becomes flutter3d + /// `(x, height, y)`, and rotation is about the world Y axis — the + /// convention every existing flutter3d floor/camera demo already assumes + /// (`OrbitController`, every showcase page with a floor). + factory BridgePlane.ground({double height = 0.0}) => + BridgePlane(axis: PlaneAxis.y, constant: height); + + /// A vertical backdrop at [depth]: Flame `(x, y)` becomes flutter3d + /// `(x, y, depth)` — a side-scroller's own plane, Z held fixed instead of + /// Y. Flame's `y` grows downward on screen and flutter3d's grows upward, + /// so this flips it by default; pass `flipY: false` to keep the two + /// aligned literally instead of visually. + factory BridgePlane.backdrop({double depth = 0.0, bool flipY = true}) => + BridgePlane(axis: PlaneAxis.z, constant: depth, flipY: flipY); + + /// Which flutter3d axis stays fixed at [constant]. + final PlaneAxis axis; + + /// The flutter3d coordinate held constant across the whole plane. + final double constant; + + /// Negates Flame's `y` before it becomes a flutter3d coordinate. See + /// [BridgePlane.backdrop] for why a vertical plane defaults this on. + final bool flipY; + + /// [flat] as a point in flutter3d space, at this plane's own [constant] + /// unless [at] names a different one — a jump's own height above a + /// ground plane, say. + Vector3 to3d(Vector2 flat, {double? at}) { + final y = flipY ? -flat.y : flat.y; + final held = at ?? constant; + return switch (axis) { + PlaneAxis.y => Vector3(flat.x, held, y), + PlaneAxis.z => Vector3(flat.x, y, held), + }; + } + + /// [to3d] into [out], for a caller writing every frame that should not + /// make a vector each time; [x] and [y] are Flame's. + void to3dInto(double x, double y, Vector3 out, {double? at}) { + final down = flipY ? -y : y; + final held = at ?? constant; + switch (axis) { + case PlaneAxis.y: + out.setValues(x, held, down); + case PlaneAxis.z: + out.setValues(x, down, held); + } + } + + /// [to3dInto] for [BridgeSpace]: [lift] is along the normal from + /// [constant]. + @override + void place(double x, double y, double lift, Vector3 out) => + to3dInto(x, y, out, at: constant + lift); + + /// [rotationInto] for [BridgeSpace]; the same turn anywhere on a plane. + @override + void turn(double x, double y, double angle, Quaternion out) => + rotationInto(angle, out); + + /// [point]'s coordinates on this plane, dropping the constant axis. + Vector2 to2d(Vector3 point) { + final flat = switch (axis) { + PlaneAxis.y => Vector2(point.x, point.z), + PlaneAxis.z => Vector2(point.x, point.y), + }; + return flipY ? Vector2(flat.x, -flat.y) : flat; + } + + /// The axis a rotation around this plane's normal turns about — world Y + /// for a ground plane, world Z for a backdrop. + Vector3 get normal => switch (axis) { + PlaneAxis.y => Vector3(0.0, 1.0, 0.0), + PlaneAxis.z => Vector3(0.0, 0.0, 1.0), + }; + + /// A flutter3d rotation turning [angle] radians about this plane's normal, + /// Flame's own sense of positive (clockwise on screen): the node's +X is + /// drawn along where [to3d] puts Flame's `(cos angle, sin angle)`. + /// + /// **Measured against the matrix a node is drawn with, not against + /// `Quaternion.rotated`.** `vector_math`'s `axisAngle(axis, θ)` is an + /// ordinary quaternion, and `Matrix4.compose`, which a `SceneNode` draws + /// through, turns by the right-hand `+θ`. `rotated(v)` computes `q̄·v·q` + /// and turns by `-θ`. This used to take its sign from `rotated`, and on a + /// ground plane a Flame turn drew mirrored: +0.5 clockwise on screen came + /// out anticlockwise. A backdrop happened to come out right, because there + /// the two sign flips cancelled, and the round trip through [angleFor] + /// agreed with itself either way, which is why nothing caught it. + /// + /// About Y, a right-hand turn of `φ` takes +X to `(cos φ, 0, -sin φ)`; + /// about Z, to `(cos φ, sin φ, 0)`. Matching those to [to3d]'s direction + /// gives `φ` below. + Quaternion rotationFor(double angle) { + final phi = switch (axis) { + PlaneAxis.y => flipY ? angle : -angle, + PlaneAxis.z => flipY ? -angle : angle, + }; + return Quaternion.axisAngle(normal, phi); + } + + /// [rotationFor] into [out], without making a quaternion. + void rotationInto(double angle, Quaternion out) { + final phi = switch (axis) { + PlaneAxis.y => flipY ? angle : -angle, + PlaneAxis.z => flipY ? -angle : angle, + }; + final half = Portable.sin(phi / 2.0); + final w = Portable.cos(phi / 2.0); + switch (axis) { + case PlaneAxis.y: + out.setValues(0.0, half, 0.0, w); + case PlaneAxis.z: + out.setValues(0.0, 0.0, half, w); + } + } + + /// The scalar angle [rotation] turns about this plane's normal, inverting + /// [rotationFor] for the component that carries angle the other way. + /// + /// Read off by turning the plane's own zero direction — flutter3d's world + /// +X — through [rotation]'s own matrix, the one the node is drawn with, + /// and measuring where it landed with the right-hand formula for whichever + /// axis is [normal] (`atan2(y, x)` about Z, `atan2(-z, x)` about Y); see + /// [rotationFor] for why not through `Quaternion.rotated`. A rotation with + /// any component off this plane's normal has no single answer here; this + /// reports only the turn around the normal, which is the whole of what a + /// `double angle` can hold. + double angleFor(Quaternion rotation) { + final turned = rotation.asRotationMatrix().transform( + Vector3(1.0, 0.0, 0.0), + ); + final sinComponent = switch (axis) { + PlaneAxis.y => -turned.z, + PlaneAxis.z => turned.y, + }; + // `Portable.atan2`, not `dart:math`'s: this can run inside a bridged + // game's own deterministic step (a synced actor's rotation read back for + // gameplay logic), and the platform's own libm disagrees with itself in + // the last few bits between the Dart VM and a browser — `portable_math` + // exists in `flutter3d_sim` for exactly this reason. + final measured = Portable.atan2(sinComponent, turned.x); + // [rotationFor] read backwards. + return switch (axis) { + PlaneAxis.y => flipY ? measured : -measured, + PlaneAxis.z => flipY ? -measured : measured, + }; + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/projector.dart b/packages/flame_flutter3d/lib/src/transform/projector.dart new file mode 100644 index 00000000000..8d345db1a2f --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/projector.dart @@ -0,0 +1,175 @@ +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:vector_math/vector_math.dart'; + +/// Between Flame's screen and the 3D camera: where a point of the scene is +/// drawn, and which point of a [BridgePlane] is under a touch. +/// +/// **What a Flame overlay and a Flame pointer need from a perspective 3D +/// layer.** A label over a craft, a "+30" where a target went down, lives +/// in Flame's viewport, in screen pixels; the craft lives in the scene. A +/// crosshair a finger puts on the ground is a screen point that has to +/// become a point on the plane the game plays on. With an orthographic +/// camera the two are one scale apart; with a perspective one they are a +/// projection apart, and every game that wanted either wrote it by hand. +/// +/// [viewSize] is read on every call, so it follows a resize: pass the Flame +/// game's own `size`, which is the canvas both layers share. +/// +/// **One view of several.** [viewport] is the part of the canvas [camera] +/// is drawn into, as a `RenderView.viewportFraction` says: the left half of +/// a split screen. Screen points stay the canvas's, and the camera's lens +/// is that part's shape. Null is the whole canvas. +final class BridgeProjector { + BridgeProjector({ + required this.camera, + required this.viewSize, + this.viewport, + }); + + final CameraNode camera; + final Vector2 Function() viewSize; + + /// The part of the canvas [camera] draws into, read on every call; null + /// for all of it. + final ViewportRect Function()? viewport; + + /// Where [camera]'s picture is on the canvas, in logical pixels, or null + /// while the canvas has no size. + ({double x, double y, double width, double height})? _area() { + final size = viewSize(); + if (size.x <= 0.0 || size.y <= 0.0) { + return null; + } + final part = viewport?.call(); + if (part == null) { + return (x: 0.0, y: 0.0, width: size.x, height: size.y); + } + return ( + x: part.x * size.x, + y: part.y * size.y, + width: part.width * size.x, + height: part.height * size.y, + ); + } + + /// Where [point] is drawn, in logical pixels from the top left, or null + /// when it is behind the camera. + /// + /// **Behind an orthographic camera too.** A perspective projection + /// divides by depth and sends a point behind the eye away; an orthographic + /// one does not, and a point behind it came back drawn as if in front. + /// It is asked of the camera's own space. + Vector2? toScreen(Vector3 point) { + final area = _area(); + if (area == null) { + return null; + } + if (camera.projection is OrthographicProjection && + camera.viewMatrix.transformed3(point).z > 0.0) { + return null; + } + final at = projectPoint( + camera.viewProjection(area.width / area.height), + point, + width: area.width, + height: area.height, + ); + return at == null ? null : Vector2(at.x + area.x, at.y + area.y); + } + + /// The rectangle on the screen [box] covers, in logical pixels, or null + /// when all of it is behind the camera. A box partly behind it covers the + /// whole view, as `screenBoundsOfBox` explains. + ScreenBounds? boundsOf(Aabb3 box) { + final area = _area(); + if (area == null) { + return null; + } + final bounds = screenBoundsOfBox( + camera.viewProjection(area.width / area.height), + box, + width: area.width, + height: area.height, + ); + if (bounds == null) { + return null; + } + return ( + left: bounds.left + area.x, + top: bounds.top + area.y, + right: bounds.right + area.x, + bottom: bounds.bottom + area.y, + ); + } + + /// The point of [plane] under [screen], in Flame's coordinates on that + /// plane, or null when the ray from the camera through it never meets + /// the plane in front of the camera: a touch on the sky. + Vector2? onPlane(Vector2 screen, BridgePlane plane) { + final ray = rayThrough(screen); + if (ray == null) { + return null; + } + final (near, far) = ray; + final normal = plane.normal; + final start = near.dot(normal); + final run = far.dot(normal) - start; + if (run.abs() < 1e-12) { + return null; + } + final t = (plane.constant - start) / run; + if (t < 0.0) { + return null; + } + // Parenthesised: a cascade binds to the whole sum, and `near + (far - + // near)..scale(t)` scaled the far point instead of the step towards it. + return plane.to2d(near + ((far - near)..scale(t))); + } + + /// [onPlane], or for a touch on the sky the point of [plane] straight + /// under where the ray leaves the view: out at the horizon, in the + /// direction the finger points. Finite either way, for what cannot take a + /// NaN, a drag that strays above the horizon say. + Vector2? onPlaneOrHorizon(Vector2 screen, BridgePlane plane) { + final hit = onPlane(screen, plane); + if (hit != null) { + return hit; + } + final ray = rayThrough(screen); + return ray == null ? null : plane.to2d(ray.$2); + } + + /// The near and far ends of the ray from the camera through [screen]: + /// where a tap enters the scene, and where it leaves the view. + (Vector3, Vector3)? rayThrough(Vector2 screen) { + final area = _area(); + if (area == null) { + return null; + } + final inverse = Matrix4.copy( + camera.viewProjection(area.width / area.height), + ); + if (inverse.invert() == 0.0) { + return null; + } + final ndcX = (screen.x - area.x) / area.width * 2.0 - 1.0; + final ndcY = 1.0 - (screen.y - area.y) / area.height * 2.0; + // Clip-space depth runs 0 at the near plane to 1 at the far one in this + // engine; see `projectPoint`. + final near = _unproject(inverse, ndcX, ndcY, 0.0); + final far = _unproject(inverse, ndcX, ndcY, 1.0); + if (near == null || far == null) { + return null; + } + return (near, far); + } + + static Vector3? _unproject(Matrix4 inverse, double x, double y, double z) { + final v = inverse.transform(Vector4(x, y, z, 1.0)); + if (v.w.abs() < 1e-12) { + return null; + } + return Vector3(v.x / v.w, v.y / v.w, v.z / v.w); + } +} diff --git a/packages/flame_flutter3d/lib/src/transform/sprite_billboard_component.dart b/packages/flame_flutter3d/lib/src/transform/sprite_billboard_component.dart new file mode 100644 index 00000000000..c8770be2691 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/transform/sprite_billboard_component.dart @@ -0,0 +1,259 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/components.dart'; +import 'package:flame/sprite.dart' show SpriteAnimationTicker; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/transform/billboard_atlas.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_sim/flutter3d_sim.dart' show Portable; + +/// A Flame [Sprite], or a [SpriteAnimation], drawn in the scene on a card +/// that turns to face the camera: a racing car seen from behind, a tree by +/// the road, an explosion, the flat sprites a cartridge-era game is made of +/// standing in a 3D world. +/// +/// **Flame's sprites, not a second kind.** The picture is the sprite's own +/// image, cut where the sprite says; an animation is Flame's, played by +/// its `SpriteAnimationTicker` on Flame's clock, so its frames, its timing, +/// its looping and its `onComplete` are what a flat Flame game has, and +/// [removeOnFinish] takes a one-shot away when it has played, as it does a +/// `SpriteAnimationComponent`. The image goes to the device once; each part +/// of it a frame shows is a card of its own corners. +/// +/// **Share an `atlas`.** Many billboards of one sprite sheet, a bank of +/// reeds, should be handed the game's [BillboardAtlas], and draw with one +/// texture and one material; one without makes its own, and lets it go +/// when it goes. +/// +/// **Standing on the plane.** The card is [cardHeight] metres tall and as +/// wide as the sprite's shape makes it, its foot at the component's place: +/// a car on the road, not sunk into it. [upright] turns it about the plane's +/// normal only, as a tree should; otherwise it faces the camera squarely, +/// as a spark may. The camera is the game's `camera3d` unless [faces] is +/// given. +/// +/// Drawn unlit, cut out where the sprite is clear, and sampled nearest, as +/// pixel art wants, or [smooth] for lettering; `tint` and `opacity` colour +/// and fade it. +class SpriteBillboardComponent extends Object3dComponent { + SpriteBillboardComponent({ + required this.device, + required super.scene, + required super.plane, + Sprite? sprite, + SpriteAnimation? animation, + BillboardAtlas? atlas, + this.cardHeight = 1.0, + this.upright = true, + this.faces, + this.removeOnFinish = false, + this.smooth = false, + super.position, + super.elevation, + super.priority, + }) : assert( + (sprite == null) != (animation == null), + 'a sprite or an animation, and one of them', + ), + _sprite = sprite, + _atlas = atlas ?? BillboardAtlas(device), + _ownsAtlas = atlas == null, + ticker = animation?.createTicker(), + super( + node: SceneNode(name: 'sprite billboard'), + direction: SyncDirection.flameToScene, + ); + + final GraphicsDevice device; + + /// How tall the card stands, in metres. + final double cardHeight; + + /// Whether the card turns about the plane's normal only. + final bool upright; + + /// The camera it faces; the game's `camera3d` when null. + final CameraNode? faces; + + /// Whether a one-shot animation takes the component away once played. + final bool removeOnFinish; + + /// Whether the picture is sampled linearly rather than nearest: for + /// lettering, from `BillboardAtlas.spriteOfText`, and anything not pixel + /// art. + final bool smooth; + + Sprite? _sprite; + + /// The sprite whose picture the card is drawn with now. + Sprite? _showing; + + /// Shows [next] instead of the sprite it had: a sign that says something + /// else, a score that went up. One of another image is uploaded first and + /// shown when it is; an animation's billboard keeps playing its frames. + set sprite(Sprite next) { + if (ticker != null) { + return; + } + _sprite = next; + final card = _card; + if (card == null) { + return; + } + _atlas.materialOf(next.image, smooth: smooth).then((material) { + if (material == null || !identical(_sprite, next)) { + return; + } + card.material = material; + _showing = next; + _showFrame(); + }); + } + + final BillboardAtlas _atlas; + final bool _ownsAtlas; + + /// The animation's ticker, when it is an animation: Flame's own, to pause, + /// reset or listen to. + final SpriteAnimationTicker? ticker; + + /// The sprite drawn now. + Sprite get currentSprite => _sprite ?? ticker!.getSprite(); + + MeshNode? _card; + + @override + Future onLoad() async { + await super.onLoad(); + final material = await _atlas.materialOf( + currentSprite.image, + smooth: smooth, + ); + if (material == null) { + return; + } + _showing = currentSprite; + final card = _card = MeshNode(_atlas.cardOf(currentSprite), material); + visual.add(card); + _showFrame(); + } + + @override + void update(double dt) { + final playing = ticker; + if (playing != null) { + playing.update(dt); + if (removeOnFinish && playing.done()) { + removeFromParent(); + } + } + super.update(dt); + } + + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + _showFrame(); + _face(); + } + + /// Shows the sprite now on the card, and sizes the card to its shape. + void _showFrame() { + final card = _card; + if (card == null) { + return; + } + // A sprite of a picture still going up keeps the last one's card until + // its material is there to draw it. + final sprite = ticker?.getSprite() ?? _showing ?? currentSprite; + final showing = _atlas.cardOf(sprite); + if (!identical(card.mesh, showing)) { + card.mesh = showing; + } + final wide = cardHeight * sprite.srcSize.x / sprite.srcSize.y; + final scale = card.readScale(); + if (scale.x != wide || scale.y != cardHeight) { + card.setScale(wide, cardHeight, 1.0); + } + } + + final Quaternion _toward = Quaternion.identity(); + final Quaternion _written = Quaternion(double.nan, 0.0, 0.0, 0.0); + + /// Turns [visual] so the card faces the camera, whatever [node] is turned + /// by. + /// + /// **Written only when it changed.** A setter marks the node moved whatever + /// it is handed, so a still card under a still camera invalidated its + /// shadow and its bounds every frame. + void _face() { + final eye = faces ?? _gameCamera(); + if (eye == null) { + return; + } + final from = node.readWorldPosition(); + final to = eye.readWorldPosition()..sub(from); + if (upright) { + final up = plane.normal; + to.sub(up * to.dot(up)); + if (to.length2 == 0.0) { + return; + } + // About the plane's normal, from the card's +Z as it lies in the plane + // to the camera. It turned about world Y whatever the plane, which is + // the normal only of a floor: on a backdrop the card swung about an + // axis lying in its own plane. + final forward = Vector3(0.0, 0.0, 1.0)..sub(up * up.z); + if (forward.length2 < 1e-12) { + // The card faces along the normal already; turning it about the + // normal only spins it on the spot. + _toward.setValues(0.0, 0.0, 0.0, 1.0); + } else { + forward.normalize(); + to.normalize(); + final angle = Portable.atan2( + up.dot(forward.cross(to)), + forward.dot(to), + ); + _toward.setAxisAngle(up, angle); + } + } else { + if (to.length2 == 0.0) { + return; + } + _toward.setFromTwoVectors(Vector3(0.0, 0.0, 1.0), to.normalized()); + } + final turn = (node.readRotation()..inverse()) * _toward; + if (turn.x == _written.x && + turn.y == _written.y && + turn.z == _written.z && + turn.w == _written.w) { + return; + } + _written.setFrom(turn); + visual.setRotation(turn); + } + + CameraNode? _gameCamera() => switch (findGame()) { + final HasFlutter3d game => game.camera3d, + _ => null, + }; + + /// Its own atlas goes with it, after the frames in flight, and not when + /// Flame only moves it; a shared one is the game's. + @override + void onRemove() { + if (_ownsAtlas) { + final game = findGame(); + final drawing = game is HasFlutter3d ? game.renderer : null; + scheduleMicrotask(() { + if (isMounted || parent != null) { + return; + } + _atlas.dispose(drawing: drawing); + }); + } + super.onRemove(); + } +} diff --git a/packages/flame_flutter3d/lib/src/world/atmosphere_component.dart b/packages/flame_flutter3d/lib/src/world/atmosphere_component.dart new file mode 100644 index 00000000000..e3dcf9386a2 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/atmosphere_component.dart @@ -0,0 +1,56 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A day of [AtmosphereCycle] run on Flame's clock, on the 3D world of the +/// `HasFlutter3d` game it is in: the sky, the ambient light and [sun]. +/// +/// [time] advances by [rate] a second (a game whose day lasts a race sets +/// it so), and [current] is the air right now. +/// +/// **The fog reaches the frame by itself.** It is the one part a scene does +/// not hold, and a game had to know to read [fog] into its own +/// `renderSettings`; one that did not saw a sky turn to dusk over a road +/// still in noon's haze. It is written into `HasFlutter3d.fog3d`, which +/// the game's settings draw with unless it chose its own. +/// +/// The sky is written into the game's `clearColor`. A +/// `Flutter3dFlameWidget` handed a `clearColor` of its own draws that +/// instead, as it says: leave it out for the day to show. +class AtmosphereComponent extends Component { + AtmosphereComponent({ + required this.cycle, + this.sun, + this.time = 0.0, + this.rate = 1.0, + }) : current = cycle.at(time); + + final AtmosphereCycle cycle; + + /// The light the sun's colour and intensity go onto, if any. + final LightNode? sun; + + /// Where in the cycle the day is. + double time; + + /// How much [time] passes a second of play. + double rate; + + /// The air now. + Atmosphere current; + + /// The fog to draw with now. + FogSettings get fog => current.fog; + + @override + void update(double dt) { + super.update(dt); + time += dt * rate; + current = cycle.at(time); + final game = findGame(); + if (game is HasFlutter3d && game.has3d) { + current.applyTo(game.scene, sun: sun, clearColor: game.clearColor); + game.fog3d = current.fog; + } + } +} diff --git a/packages/flame_flutter3d/lib/src/world/cell_grid_component.dart b/packages/flame_flutter3d/lib/src/world/cell_grid_component.dart new file mode 100644 index 00000000000..11cabcc2d52 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/cell_grid_component.dart @@ -0,0 +1,284 @@ +import 'dart:async' show scheduleMicrotask; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart' show Vector2; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:vector_math/vector_math.dart' show Matrix4, Vector3, Vector4; + +/// A [CellGrid] as a bridged component: drawn as blocks, worn away by +/// [hitAt], grown by [setCell], and a world to move and collide in. A Space +/// Invaders shield, a Pac-Man maze, a Dig Dug field, a Surround arena. +/// +/// The grid's corner is at this component's position, cell (0, 0) at the +/// top left as Flame sees it, one [CellGrid.cell] a cell; [size] is the +/// grid's. [cellAt] and [centreOf] turn a point of the game into a cell and +/// back, for a `GridMover` walking the maze or a game asking what is where. +/// +/// **Two ways to draw it.** Merged, the default, the cells are one mesh, +/// drawn again from what is left after every change: cheap to draw, and a +/// whole grid rebuilt for each cell. [instanced], each cell is a slot in one +/// `InstancedMeshNode`, and a cell taken or put back is one slot: a field of +/// thousands dug a cell at a time rebuilt thousands of blocks for every +/// swing of the spade. +/// +/// **Flame's collision sees every cell.** With [hitboxes], each cell there +/// has a passive `RectangleHitbox` of its own, taken away with it, so a ball +/// or a ghost meets the walls through Flame's own `CollisionCallbacks` and +/// Flame's own raycast, and [cellAt] of the contact point says which. One +/// `RectangleHitbox()` round the whole grid, as a shield uses, says only +/// that something reached it. +class CellGridComponent extends Object3dComponent { + CellGridComponent({ + required this.grid, + required this.device, + required super.scene, + required super.plane, + required this.material, + this.depth, + this.colour, + this.instanced = false, + this.hitboxes = false, + super.position, + super.elevation, + }) : super( + node: SceneNode(name: 'cell grid'), + direction: SyncDirection.flameToScene, + size: Vector2(grid.columns * grid.cell, grid.rows * grid.cell), + ) { + if (instanced) { + _placeAll(); + } else { + _rebuild(); + } + } + + final CellGrid grid; + final GraphicsDevice device; + final engine.Material material; + + /// How deep the blocks stand; a cell's size unless given. + final double? depth; + + /// A colour the blocks are painted, if any. + final Vector4? colour; + + /// Whether each cell is a slot in one instanced batch rather than a part + /// of one merged mesh; see the class doc. + final bool instanced; + + /// Whether each cell there has a Flame hitbox of its own. + final bool hitboxes; + + MeshNode? _blocks; + InstancedMeshNode? _batch; + final Map _slots = {}; + final Map _cellHitboxes = {}; + + BridgePlane get _flat => + BridgePlane(axis: plane.axis, constant: 0.0, flipY: plane.flipY); + + /// The cell [at], a point of the game, falls in, as (column, row); it may + /// be outside the grid. + (int, int) cellAt(Vector2 at) { + final local = absoluteToLocal(at); + return ((local.x / grid.cell).floor(), (local.y / grid.cell).floor()); + } + + /// The middle of cell ([column], [row]), in this component's parent's + /// space: where a sibling standing in it is placed. + Vector2 centreOf(int column, int row) => Vector2( + position.x + (column + 0.5) * grid.cell, + position.y + (row + 0.5) * grid.cell, + ); + + /// Takes away the cells within [radius] metres of [at], a point in the + /// game's own coordinates, and draws what is left. True when a cell was + /// there to take: the shot hit the shield rather than passing through a + /// hole in it. + bool hitAt(Vector2 at, {double radius = 0.6}) { + final local = absoluteToLocal(at); + var gone = false; + for (var r = 0; r < grid.rows; r++) { + for (var c = 0; c < grid.columns; c++) { + final dx = (c + 0.5) * grid.cell - local.x; + final dy = (r + 0.5) * grid.cell - local.y; + if (dx * dx + dy * dy <= radius * radius && grid.isAlive(c, r)) { + _change(c, r, alive: false); + gone = true; + } + } + } + if (gone && !instanced) { + _rebuild(); + } + return gone; + } + + /// Puts cell ([column], [row]) there, or takes it away, and draws the + /// change. True when it changed. + bool setCell(int column, int row, {bool alive = true}) { + if (!_change(column, row, alive: alive)) { + return false; + } + if (!instanced) { + _rebuild(); + } + return true; + } + + bool _change(int column, int row, {required bool alive}) { + if (!grid.set(column, row, alive: alive)) { + return false; + } + final index = row * grid.columns + column; + if (instanced) { + if (alive) { + _place(column, row); + } else { + final slot = _slots.remove(index); + if (slot != null && slot.live) { + _batch?.release(slot); + } + } + } + if (hitboxes && isMounted) { + if (alive) { + _addHitbox(column, row); + } else { + _cellHitboxes.remove(index)?.removeFromParent(); + } + } + return true; + } + + @override + void onMount() { + super.onMount(); + if (!hitboxes) { + return; + } + for (var r = 0; r < grid.rows; r++) { + for (var c = 0; c < grid.columns; c++) { + if (grid.isAlive(c, r)) { + _addHitbox(c, r); + } + } + } + } + + void _addHitbox(int column, int row) { + final index = row * grid.columns + column; + if (_cellHitboxes.containsKey(index)) { + return; + } + // Solid: a ball wholly inside a cell touches none of its edges, and + // Flame reports a shape inside another only when the outer one is. + final box = RectangleHitbox( + position: Vector2(column * grid.cell, row * grid.cell), + size: Vector2.all(grid.cell), + collisionType: CollisionType.passive, + isSolid: true, + ); + _cellHitboxes[index] = box; + add(box); + } + + void _placeAll() { + final block = CuboidShape( + size: Vector3(grid.cell, depth ?? grid.cell, grid.cell), + ).build(); + final batch = _batch = InstancedMeshNode( + DeviceMesh.upload( + device, + colour == null ? block : block.withColor(colour!), + ), + material, + capacity: grid.columns * grid.rows, + name: 'cell grid blocks', + ); + node.add(batch); + for (var r = 0; r < grid.rows; r++) { + for (var c = 0; c < grid.columns; c++) { + if (grid.isAlive(c, r)) { + _place(c, r); + } + } + } + } + + void _place(int column, int row) { + final batch = _batch; + if (batch == null) { + return; + } + final slot = batch.acquire(); + slot.setTransform( + Matrix4.translation( + _flat.to3d( + Vector2((column + 0.5) * grid.cell, (row + 0.5) * grid.cell), + ), + ), + ); + _slots[row * grid.columns + column] = slot; + } + + void _rebuild() { + final flat = _flat; + final data = grid.mesh( + place: (x, y) => flat.to3d(Vector2(x, y)), + depth: depth, + colour: colour, + ); + final old = _blocks; + if (data == null) { + old?.visible = false; + } else { + final blocks = MeshNode(DeviceMesh.upload(device, data), material); + node.add(blocks); + _blocks = blocks; + } + if (old != null && data != null) { + old.removeFromParent(); + _letGo(old.mesh as DeviceMesh); + } + } + + /// **The last mesh goes with the grid.** Each hit gave the mesh before it + /// back, and the one standing when the shield was removed stayed on the + /// device: a level of four shields leaked four. Let go a moment later, as + /// Object3dComponent lets go of what it owns, so a grid moved to another + /// parent keeps it. + @override + void onRemove() { + final mesh = _blocks?.mesh as DeviceMesh? ?? _batch?.mesh as DeviceMesh?; + final game = findGame(); + final drawing = game is HasFlutter3d ? game.renderer : null; + if (mesh != null) { + scheduleMicrotask(() { + if (isMounted || parent != null) { + return; + } + _blocks?.removeFromParent(); + _blocks = null; + _letGo(mesh, drawing); + }); + } + super.onRemove(); + } + + void _letGo(DeviceMesh mesh, [Renderer? through]) { + final game = findGame(); + final drawing = through ?? (game is HasFlutter3d ? game.renderer : null); + if (drawing != null) { + drawing.releaseMeshAfterFrame(mesh); + } else { + device + ..releaseGeometry(mesh.vertices) + ..releaseGeometry(mesh.indices); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/world/chunk_streamer.dart b/packages/flame_flutter3d/lib/src/world/chunk_streamer.dart new file mode 100644 index 00000000000..f5b7ba67dd4 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/chunk_streamer.dart @@ -0,0 +1,57 @@ +/// The pieces of an endless world that are built right now, by index: a +/// river's stretches, a road's segments, a scrolling level's screens. +/// +/// **What every game that scrolls wrote by hand.** Build what comes into +/// view, let go of what has left it, rebuild from scratch on a restart, and +/// find the one piece a point falls in. Each got the edges wrong once: a +/// piece dropped while still in view behind the player, or built twice when +/// the window moved by less than a piece. [cover] is the one place that +/// decides, and the game says only how a piece is built and let go. +/// +/// A plain class rather than a component: what a piece holds (scene nodes, +/// Flame components, buffers) is the game's, and [build] and [drop] are +/// where it adds and removes them. Call [cover] from the game's `update`, +/// with the range of indices the camera can see. +final class ChunkStreamer { + ChunkStreamer({required this.build, required this.drop}); + + /// Makes the piece at an index, adding whatever it holds to the game. + final C Function(int index) build; + + /// Takes the piece at an index out of the game, and lets go of what it + /// holds. + final void Function(int index, C chunk) drop; + + final Map _chunks = {}; + + /// The piece at [index], if it is built. + C? operator [](int index) => _chunks[index]; + + /// The indices built, in no particular order. + Iterable get indices => _chunks.keys; + + /// The pieces built, in no particular order. + Iterable get chunks => _chunks.values; + + /// Makes the pieces from [from] to [to] inclusive the ones built: drops + /// every other, then builds the missing ones in order of index, so a + /// piece's [build] can look at the one before it. + void cover(int from, int to) { + for (final index + in _chunks.keys.where((i) => i < from || i > to).toList()) { + drop(index, _chunks.remove(index) as C); + } + for (var index = from; index <= to; index++) { + if (!_chunks.containsKey(index)) { + _chunks[index] = build(index); + } + } + } + + /// Drops every piece: for a restart, which [cover] then builds afresh. + void clear() { + for (final index in _chunks.keys.toList()) { + drop(index, _chunks.remove(index) as C); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/world/fixture_visuals_component.dart b/packages/flame_flutter3d/lib/src/world/fixture_visuals_component.dart new file mode 100644 index 00000000000..48296ca8a48 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/fixture_visuals_component.dart @@ -0,0 +1,38 @@ +import 'package:flame/components.dart' show Component; +import 'package:flame_flutter3d/src/host/bridge_priority.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show FixtureVisuals; + +/// A level's furniture drawn — doors on their colliders, keys spinning, a +/// taken coin gone — kept up to date on Flame's clock. +/// +/// **What every level-loading game wrote by hand.** `FixtureVisuals` is the +/// engine's drawing of a level's fixtures, and it has two duties a game has to +/// remember: `sync` once a frame, after the step has moved the doors, and +/// `dispose` when the level goes, before its device does. A bridged game had +/// neither on any clock, and a level left behind by the next one kept its +/// meshes on the device for good. Added with the level and removed with it, +/// this does both. +final class FixtureVisualsComponent extends Component { + FixtureVisualsComponent( + this.fixtures, { + super.priority = BridgePriority.camera - 1, + }); + + /// What draws the fixtures. Hand its `add` to the level's spawning. + final FixtureVisuals fixtures; + + double _elapsed = 0.0; + + @override + void update(double dt) { + super.update(dt); + _elapsed += dt; + fixtures.sync(_elapsed); + } + + @override + void onRemove() { + fixtures.dispose(); + super.onRemove(); + } +} diff --git a/packages/flame_flutter3d/lib/src/world/grid_mover.dart b/packages/flame_flutter3d/lib/src/world/grid_mover.dart new file mode 100644 index 00000000000..588df8a76dd --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/grid_mover.dart @@ -0,0 +1,171 @@ +import 'package:flame/components.dart'; + +import 'package:flame_flutter3d/src/host/has_fixed_step.dart'; +import 'package:flame_flutter3d/src/world/cell_grid_component.dart'; + +/// Which way a [GridMover] goes, as the screen sees it: up is towards the +/// top, Flame's `y` falling. +enum GridHeading { + none(0, 0), + up(0, -1), + down(0, 1), + left(-1, 0), + right(1, 0); + + const GridHeading(this.dx, this.dy); + + final int dx; + final int dy; + + /// The way back. + GridHeading get opposite => switch (this) { + none => none, + up => down, + down => up, + left => right, + right => left, + }; +} + +/// Moves the component it is added to from the middle of one cell of a +/// [CellGridComponent] to the middle of the next: Pac-Man in his maze, a +/// ghost, a digger, a cycle leaving its trail. +/// +/// **The turn waits for the junction.** A maze game is played by asking for +/// a turn before the corner: [wanted] is the way the player asks for, kept +/// until a cell's middle where that way is open, while the mover goes on in +/// [heading]. A turn back the way it came is taken at once, between cells, +/// as the arcade's did. At a wall with no open way asked for, it stops. +/// +/// Open is not a cell of the grid, unless [passable] says otherwise: the +/// blocks are the walls. With [wraps], a way off one edge comes in at the +/// other, the tunnel at the sides of the maze. +/// +/// **A behaviour, as Flame's are.** Added as a child of what it moves, a +/// `PositionComponent` sharing a parent with [grid]. It moves in the game's +/// fixed steps when the game has `HasFixedStep`, and in frames otherwise; +/// Flame's effects and hitboxes on what it moves go on working, and +/// [onArrive] is told each middle of a cell reached, for a dot to be eaten. +class GridMover extends Component with FixedStepUpdate { + GridMover({ + required this.grid, + required this.speed, + this.passable, + this.wraps = false, + this.onArrive, + }); + + /// The grid it moves on. + final CellGridComponent grid; + + /// Metres a second, read every step. + double speed; + + /// Whether a cell can be entered; a cell of the grid that is not there, + /// unless given. + final bool Function(int column, int row)? passable; + + /// Whether a way off one edge comes in at the other. + final bool wraps; + + /// Told the cell whose middle has just been reached. + final void Function(int column, int row)? onArrive; + + /// The way it is going; none while it stands. + GridHeading heading = GridHeading.none; + + /// The way asked for, kept until it can be taken. + GridHeading wanted = GridHeading.none; + + int _column = 0; + int _row = 0; + bool _stepped = false; + + /// The cell it left last, or stands in. + (int, int) get cell => (_column, _row); + + PositionComponent get _body => parent! as PositionComponent; + + /// Stood in the middle of the cell its component is in. + @override + void onMount() { + super.onMount(); + _stepped = findGame() is HasFixedStep; + final (column, row) = grid.cellAt(_body.absolutePosition); + _column = column; + _row = row; + _body.position.setFrom(grid.centreOf(column, row)); + } + + @override + void update(double dt) { + super.update(dt); + if (!_stepped) { + _advance(dt); + } + } + + @override + void fixedUpdate(double step) => _advance(step); + + (int, int)? _wrapped(int column, int row) { + final columns = grid.grid.columns; + final rows = grid.grid.rows; + if (wraps) { + return (column % columns, row % rows); + } + if (column < 0 || row < 0 || column >= columns || row >= rows) { + return null; + } + return (column, row); + } + + bool _open(GridHeading way) { + if (way == GridHeading.none) { + return false; + } + final next = _wrapped(_column + way.dx, _row + way.dy); + if (next == null) { + return false; + } + final (c, r) = next; + return passable?.call(c, r) ?? !grid.grid.isAlive(c, r); + } + + void _advance(double dt) { + var left = speed * dt; + final at = _body.position; + // A turn back is taken where it stands: the cell ahead becomes the one + // it left. + if (heading != GridHeading.none && wanted == heading.opposite) { + _column += heading.dx; + _row += heading.dy; + heading = wanted; + } + for (var guard = 0; guard < 64 && left > 0.0; guard++) { + final middle = grid.centreOf(_column, _row); + if (at.x == middle.x && at.y == middle.y) { + if (wanted != GridHeading.none && _open(wanted)) { + heading = wanted; + } else if (!_open(heading)) { + heading = GridHeading.none; + } + if (heading == GridHeading.none) { + return; + } + } + final target = grid.centreOf(_column + heading.dx, _row + heading.dy); + final gap = at.distanceTo(target); + if (gap > left) { + at.add((target - at)..scale(left / gap)); + return; + } + left -= gap; + final (c, r) = _wrapped(_column + heading.dx, _row + heading.dy)!; + _column = c; + _row = r; + at.setFrom(grid.centreOf(c, r)); + onArrive?.call(c, r); + } + } +} diff --git a/packages/flame_flutter3d/lib/src/world/tiled_world.dart b/packages/flame_flutter3d/lib/src/world/tiled_world.dart new file mode 100644 index 00000000000..cdc728aed18 --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/tiled_world.dart @@ -0,0 +1,137 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flame_flutter3d/src/world/cell_grid_component.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:tiled/tiled.dart'; + +/// A level drawn in the Tiled editor, stood up in 3D: its tile layers as +/// blocks, its objects as whatever the game makes of them. +/// +/// **What every game with a map read by hand.** A maze, a castle's rooms, a +/// mine's shafts are drawn far better in Tiled than typed as masks of `#`s, +/// and `flame_tiled` already reads them: `TiledComponent.load` gives its +/// `tileMap.map`, which this takes, or a test parses a `.tmx` with +/// `TiledMap.parseTmx`. Flame's own `TiledComponent` draws the tiles flat on +/// its canvas; here each tile layer becomes a [CellGridComponent], a block +/// where a tile is, on the game's plane. +/// +/// **Set up in Tiled, not in code.** A tile layer's custom properties say +/// what it is: `solid` gives its cells Flame hitboxes, walls a ghost meets; +/// `depth` is how tall its blocks stand and `elevation` how far off the +/// plane; `merged` draws it as one mesh instead of instances, for a layer +/// that never changes. Its tint colour paints it. [material] gives each +/// layer its look. +/// +/// **Objects are the game's.** Each object of an object layer is handed to +/// [spawn] with its middle in metres, and whatever component comes back is +/// added here, beside the grids, where a `GridMover` on it walks them. +/// +/// One tile is [cell] metres, the tile's top left at `(0, 0)` of this +/// component. +class TiledWorld3d extends PositionComponent { + TiledWorld3d({ + required this.map, + required this.device, + required this.scene, + required this.plane, + required this.material, + this.cell = 1.0, + this.spawn, + super.position, + }) : super(size: Vector2(map.width * cell, map.height * cell)); + + /// The level, as `flame_tiled` or `TiledMap.parseTmx` read it. + final TiledMap map; + + final GraphicsDevice device; + final Scene scene; + final BridgePlane plane; + + /// The look of a tile layer's blocks. + final engine.Material Function(TileLayer layer) material; + + /// Metres a tile. + final double cell; + + /// Makes the game's component for an object, placed at its middle in + /// metres from this component's corner; null leaves it out. + final Component? Function(TiledObject object, Vector2 at)? spawn; + + /// Each tile layer's grid, by the layer's name. + final Map grids = {}; + + @override + Future onLoad() async { + await super.onLoad(); + await _read(map.layers); + } + + Future _read(List layers) async { + for (final layer in layers) { + if (!layer.visible) { + continue; + } + switch (layer) { + case final Group group: + await _read(group.layers); + case final TileLayer tiles: + final grid = _gridOf(tiles); + grids[tiles.name] = grid; + add(grid); + case final ObjectGroup objects: + for (final object in objects.objects) { + final made = spawn?.call(object, _middleOf(object)); + if (made != null) { + add(made); + } + } + default: + break; + } + } + } + + CellGridComponent _gridOf(TileLayer layer) { + final grid = CellGrid(columns: layer.width, rows: layer.height, cell: cell); + final rows = layer.tileData ?? const >[]; + for (var r = 0; r < rows.length; r++) { + for (var c = 0; c < rows[r].length; c++) { + if (rows[r][c].tile != 0) { + grid.set(c, r); + } + } + } + final properties = layer.properties; + final tint = layer.tintColor; + return CellGridComponent( + grid: grid, + device: device, + scene: scene, + plane: plane, + material: material(layer), + depth: _number(properties.getValue('depth')), + elevation: _number(properties.getValue('elevation')) ?? 0.0, + instanced: !(properties.getValue('merged') ?? false), + hitboxes: properties.getValue('solid') ?? false, + colour: tint == null + ? null + : Vector4(tint.red / 255.0, tint.green / 255.0, tint.blue / 255.0, 1), + ); + } + + /// An object's middle in metres. A tile object is anchored at its bottom + /// left in Tiled, a shape at its top left, and a point is where it is. + Vector2 _middleOf(TiledObject object) { + final x = object.x + object.width / 2.0; + final y = object.gid != null + ? object.y - object.height / 2.0 + : object.y + object.height / 2.0; + return Vector2(x / map.tileWidth * cell, y / map.tileHeight * cell); + } + + static double? _number(Object? value) => switch (value) { + final num n => n.toDouble(), + _ => null, + }; +} diff --git a/packages/flame_flutter3d/lib/src/world/trail_component.dart b/packages/flame_flutter3d/lib/src/world/trail_component.dart new file mode 100644 index 00000000000..e067d11ebec --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/trail_component.dart @@ -0,0 +1,118 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/host/has_flutter3d.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A line drawn behind the bridged component it is added to: a missile's +/// smoke, a comet's tail. Missile Command. +/// +/// A point is laid every [spacing] metres the component moves, up to +/// [length] points, the oldest let go as new ones come; the line is a +/// `LineStripNode` in the `HasFlutter3d` game's scene, [width] pixels +/// across, and goes, its mesh with it, when this component does. +class TrailComponent extends Component { + TrailComponent({ + this.spacing = 0.5, + this.length = 48, + this.width = 3.0, + Vector4? colour, + }) : colour = colour ?? Vector4.all(1.0); + + final double spacing; + final int length; + final double width; + final Vector4 colour; + + LineStripNode? _line; + HasFlutter3d? _host; + + /// The line, while this is in a game with a 3D world. + LineStripNode? get line => _line; + + @override + void onMount() { + super.onMount(); + final game = findGame(); + if (game is! HasFlutter3d || !game.has3d) { + return; + } + _host = game; + final line = LineStripNode( + device: game.device, + material: engine.Material.polyline( + viewportWidth: game.size.x, + viewportHeight: game.size.y, + ), + capacity: length, + width: width, + colour: colour, + name: 'trail', + ); + game.scene.add(line); + _line = line; + } + + /// How far the component may move in one frame before the trail breaks + /// rather than drawing a line across: a jump across a wrapped world's + /// seam, a respawn. Null never breaks. + double? breakAt; + + /// Starts the trail afresh from where the component is. + void reset() => _line?.clear(); + + @override + void update(double dt) { + super.update(dt); + final line = _line; + final owner = parent; + if (line == null || owner is! Object3dComponent) { + return; + } + final at = owner.scenePosition; + final points = line.points; + final jump = breakAt; + if (points.isNotEmpty && + jump != null && + points.last.distanceTo(at) > jump) { + line.clear(); + } + if (points.isEmpty || points.last.distanceTo(at) >= spacing) { + line.append(at); + } + } + + /// **The line is as wide after a resize as before.** A polyline widens + /// against the size it was told the screen is, and a trail made at one + /// window size went thin or fat at the next. + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + final viewport = _line?.material.polylineViewport; + if (viewport != null) { + viewport + ..[0] = size.x + ..[1] = size.y; + } + } + + @override + void onRemove() { + final line = _line; + final host = _host; + _line = null; + if (line != null) { + line.removeFromParent(); + final mesh = line.mesh as DeviceMesh; + final drawing = host?.renderer; + if (drawing != null) { + drawing.releaseMeshAfterFrame(mesh); + } else { + host?.device + ?..releaseGeometry(mesh.vertices) + ..releaseGeometry(mesh.indices); + } + } + super.onRemove(); + } +} diff --git a/packages/flame_flutter3d/lib/src/world/wrap_space.dart b/packages/flame_flutter3d/lib/src/world/wrap_space.dart new file mode 100644 index 00000000000..9ac0e7d850f --- /dev/null +++ b/packages/flame_flutter3d/lib/src/world/wrap_space.dart @@ -0,0 +1,421 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; + +/// A world whose edges meet: what leaves by the right comes in on the left, +/// what leaves by the top comes in at the bottom. Asteroids' screen. +/// +/// **Wrapping a position is the easy third.** A ship half over the right +/// edge is half on the left too, and has to be drawn there; a rock drifting +/// out on the left has to be hit by a shot coming in on the right. Games +/// that wrapped the position alone had craft that blinked from one side to +/// the other and a seam nothing could be hit across. +/// +/// So, for every child within [margin] of an edge, this draws a ghost: a +/// copy of what the child's node draws, one world across, on the other side; +/// in a corner, three. And it gives the child ghost hitboxes, the same +/// shapes one world across, so Flame's own collision detection finds a +/// contact across the seam and reports it to the child itself, in its own +/// `onCollision`. Children are the bridged components added to this one. +/// +/// **One contact is one callback.** Two craft by the same edge touch twice, +/// really and through their ghosts, and two by opposite edges touch through +/// each one's ghost; each pair reported its hit twice, and a rock took +/// double damage. A ghost never meets another ghost, and of two ghosts +/// that stand for the same meeting only one takes part. A ghost has its +/// owner's hitbox's collision type, solidity and shape, polygons included. +/// +/// **Drawn as it is now.** A ghost's meshes take their owner's tint and +/// opacity every frame, so a hit flash or a fade shows on both sides. +/// +/// **Bodies wrap too.** A child placed from the scene side, a body the +/// physics steps, had its Flame position wrapped and read straight back from +/// the body on the far side of the edge; it is carried across in the scene +/// as well, still moving. A ghost is tapped as its owner is: a tap on a +/// craft seen across the seam reaches the craft. +/// +/// [min] and [max] are the world's corners in Flame's coordinates. +class WrapSpace extends Component with CustomTraversal { + WrapSpace({ + required this.min, + required this.max, + required this.scene, + this.margin = 1.0, + }) : assert(max.x > min.x && max.y > min.y, 'a world with no room'); + + final Vector2 min; + final Vector2 max; + + /// Where the ghosts are drawn. + final Scene scene; + + /// How near an edge a child has to be to have a ghost across it: about + /// the size of the biggest thing in the world. + double margin; + + double get _width => max.x - min.x; + double get _height => max.y - min.y; + + final Map _ghosts = + {}; + + /// Where [point] is, brought back inside the world. + Vector2 wrap(Vector2 point) => Vector2( + min.x + ((point.x - min.x) % _width), + min.y + ((point.y - min.y) % _height), + ); + + /// The shortest way from [from] to [to], going across an edge when that + /// is shorter: where a homing rock should turn. + Vector2 shortestWay(Vector2 from, Vector2 to) { + var dx = to.x - from.x; + var dy = to.y - from.y; + if (dx > _width / 2) { + dx -= _width; + } + if (dx < -_width / 2) { + dx += _width; + } + if (dy > _height / 2) { + dy -= _height; + } + if (dy < -_height / 2) { + dy += _height; + } + return Vector2(dx, dy); + } + + /// Brings every child that has left the world back in. One placed from + /// the scene side, a body the physics moves, is carried across in the + /// scene too ([Object3dComponent.shiftScene]), before it reads its place + /// back this frame. + @override + void update(double dt) { + super.update(dt); + for (final child in children.whereType()) { + final p = child.position; + if (p.x < min.x || p.x >= max.x || p.y < min.y || p.y >= max.y) { + final wrapped = wrap(p); + if (child is Object3dComponent && + child.direction == SyncDirection.sceneToFlame) { + child.shiftScene(child.plane.to3d(wrapped - p, at: 0.0)); + } + child.position.setFrom(wrapped); + } + } + } + + /// The boxes round [owner]'s ghosts, where they are drawn this frame: what + /// a tap on a craft seen across the seam is tested against. + Iterable ghostBoundsOf(Object3dComponent owner) sync* { + final ghosts = _ghosts[owner]; + if (ghosts == null) { + return; + } + for (final ghost in ghosts._byOffset.values) { + final box = ghost._drawing?.subtreeBounds; + if (box != null) { + yield box; + } + } + } + + @override + void updateSubtree(double dt) { + super.updateSubtree(dt); + final seen = {}; + for (final child in children.whereType()) { + if (child.isRemoving) { + continue; + } + seen.add(child); + final ghosts = _ghosts.putIfAbsent( + child, + () => _Ghosts(child, scene, this), + ); + ghosts.follow(_offsetsFor(child.position)); + } + for (final gone in _ghosts.keys.where((c) => !seen.contains(c)).toList()) { + _ghosts.remove(gone)!.clear(); + } + } + + @override + void onRemove() { + for (final ghosts in _ghosts.values) { + ghosts.clear(); + } + _ghosts.clear(); + super.onRemove(); + } + + /// Whether [owner] has a ghost [dx], [dy] across: then a meeting of that + /// ghost with a real hitbox stands for the same one as the reverse. + bool _hasGhost(Object3dComponent owner, double dx, double dy) => + _ghosts[owner]?._byOffset.containsKey((dx, dy)) ?? false; + + /// The world-sized steps across which [p] needs a ghost: none in the + /// middle, one by an edge, three in a corner. + List _offsetsFor(Vector2 p) { + final xs = [ + if (p.x > max.x - margin) -_width, + if (p.x < min.x + margin) _width, + ]; + final ys = [ + if (p.y > max.y - margin) -_height, + if (p.y < min.y + margin) _height, + ]; + return [ + for (final dx in xs) Vector2(dx, 0.0), + for (final dy in ys) Vector2(0.0, dy), + for (final dx in xs) + for (final dy in ys) Vector2(dx, dy), + ]; + } +} + +/// One child's ghosts: a copy of its drawing and of its hitboxes per offset. +final class _Ghosts { + _Ghosts(this.owner, this.scene, this.space); + + final Object3dComponent owner; + final Scene scene; + final WrapSpace space; + final Map<(double, double), _Ghost> _byOffset = <(double, double), _Ghost>{}; + + void follow(List offsets) { + final wanted = <(double, double)>{for (final o in offsets) (o.x, o.y)}; + for (final key + in _byOffset.keys.where((k) => !wanted.contains(k)).toList()) { + _byOffset.remove(key)!.clear(); + } + for (final offset in offsets) { + _byOffset.putIfAbsent(( + offset.x, + offset.y, + ), () => _Ghost(owner, scene, space, offset)).follow(); + } + } + + void clear() { + for (final ghost in _byOffset.values) { + ghost.clear(); + } + _byOffset.clear(); + } +} + +/// A copy of [owner]'s drawing and hitboxes, [offset] across the world. +final class _Ghost { + _Ghost(this.owner, this.scene, this.space, Vector2 offset) + : offset = offset.clone(), + _step = owner.plane.to3d(offset, at: 0.0); + + final Object3dComponent owner; + final Scene scene; + final WrapSpace space; + final Vector2 offset; + final Vector3 _step; + + SceneNode? _drawing; + int _drawn = -1; + final Map _hitboxes = {}; + + void follow() { + _followDrawing(); + _followHitboxes(); + } + + void _followDrawing() { + // Made again when what the node draws changes: a model dressed onto a + // primitive, a part added. + final size = _count(owner.node); + var drawing = _drawing; + if (drawing == null || size != _drawn) { + drawing?.removeFromParent(); + drawing = _drawing = _copy(owner.node); + _drawn = size; + scene.add(drawing); + } + final at = owner.node.readPosition()..add(_step); + drawing + ..setPositionFrom(at) + ..setRotation(owner.node.readRotation()) + ..visible = owner.node.visible; + final s = owner.node.readScale(); + drawing.setScale(s.x, s.y, s.z); + _tint(owner.node, drawing); + } + + /// Copies each mesh's tint onto its copy, the two trees being the same + /// shape: a hit flash or a fade out shows on the ghost too. + static void _tint(SceneNode from, SceneNode to) { + if (from is MeshNode && to is MeshNode && to.tint != from.tint) { + to.tint.setFrom(from.tint); + } + final a = from.childrenView; + final b = to.childrenView; + for (var i = 0; i < a.length && i < b.length; i++) { + _tint(a.elementAt(i), b.elementAt(i)); + } + } + + /// Whether a meeting of this ghost with [other] is told to [owner]. Never + /// one with another ghost: the real hitboxes, or a real one and a ghost, + /// meet as well and tell it. Nor one with a real hitbox of a child that + /// has the ghost opposite this one: that ghost meets [owner]'s real + /// hitbox, and [owner] hears it from there. + bool tells(ShapeHitbox other) { + if (other is _GhostHitbox) { + return false; + } + final them = other.hitboxParent; + if (them is! Object3dComponent) { + return true; + } + return !space._hasGhost(them, -offset.x, -offset.y); + } + + void _followHitboxes() { + final own = owner.children + .whereType() + .where((h) => h is! _GhostHitbox) + .toList(); + for (final gone in _hitboxes.keys.where((h) => !own.contains(h)).toList()) { + _hitboxes.remove(gone)!.removeFromParent(); + } + // The offset in the owner's own frame, which may be turned and scaled. + final here = owner.absolutePosition; + final local = + owner.absoluteToLocal(here + offset) - owner.absoluteToLocal(here); + for (final hitbox in own) { + final ghost = _hitboxes.putIfAbsent(hitbox, () { + final ShapeHitbox made = switch (hitbox) { + final CircleHitbox circle => _GhostCircle(this, circle.radius), + final PolygonHitbox polygon => _GhostPolygon(this, [ + for (final v in polygon.vertices) v.clone(), + ]), + _ => _GhostRectangle(this, hitbox.size), + }; + owner.add(made); + return made; + }); + ghost + ..position.setFrom(hitbox.position + local) + ..anchor = hitbox.anchor + ..angle = hitbox.angle + ..collisionType = hitbox.collisionType + ..isSolid = hitbox.isSolid; + if (ghost is _GhostRectangle) { + ghost.size.setFrom(hitbox.size); + } + } + } + + void clear() { + _drawing?.removeFromParent(); + _drawing = null; + for (final ghost in _hitboxes.values) { + ghost.removeFromParent(); + } + _hitboxes.clear(); + } + + static int _count(SceneNode node) { + var n = 1; + for (final child in node.childrenView) { + n += _count(child); + } + return n; + } + + /// What [node] draws, as nodes of its own: the meshes and materials are + /// shared, the transforms copied. + static SceneNode _copy(SceneNode node) { + final made = node is MeshNode + ? (MeshNode(node.mesh, node.material)..tint.setFrom(node.tint)) + : SceneNode(); + made + ..setPositionFrom(node.readPosition()) + ..setRotation(node.readRotation()); + final s = node.readScale(); + made.setScale(s.x, s.y, s.z); + for (final child in node.childrenView) { + made.add(_copy(child)); + } + return made; + } +} + +/// A hitbox standing in for one of its owner's, a world across. It tells +/// its owner of a meeting only when nothing else will: see [_Ghost.tells]. +mixin _GhostHitbox on ShapeHitbox { + _Ghost get ghost; + + final Set _told = {}; + + CollisionCallbacks? get _owner => switch (hitboxParent) { + final CollisionCallbacks owner => owner, + _ => null, + }; + + /// Runs Flame's own handling without its telling the owner: a hitbox + /// tells its parent only while it and the other both let it, and the + /// other side has to keep hearing of this ghost. + void _quietly(void Function() handle) { + triggersParentCollision = false; + try { + handle(); + } finally { + triggersParentCollision = true; + } + } + + @override + void onCollisionStart(List points, ShapeHitbox other) { + _quietly(() => super.onCollisionStart(points, other)); + if (!ghost.tells(other)) { + return; + } + _told.add(other); + _owner?.onCollisionStart(points, other.hitboxParent); + } + + @override + void onCollision(List points, ShapeHitbox other) { + _quietly(() => super.onCollision(points, other)); + if (_told.contains(other)) { + _owner?.onCollision(points, other.hitboxParent); + } + } + + @override + void onCollisionEnd(ShapeHitbox other) { + _quietly(() => super.onCollisionEnd(other)); + if (_told.remove(other)) { + _owner?.onCollisionEnd(other.hitboxParent); + } + } +} + +final class _GhostRectangle extends RectangleHitbox with _GhostHitbox { + _GhostRectangle(this.ghost, Vector2 size) : super(size: size.clone()); + + @override + final _Ghost ghost; +} + +final class _GhostCircle extends CircleHitbox with _GhostHitbox { + _GhostCircle(this.ghost, double radius) : super(radius: radius); + + @override + final _Ghost ghost; +} + +final class _GhostPolygon extends PolygonHitbox with _GhostHitbox { + _GhostPolygon(this.ghost, super.vertices); + + @override + final _Ghost ghost; +} diff --git a/packages/flame_flutter3d/pubspec.yaml b/packages/flame_flutter3d/pubspec.yaml new file mode 100644 index 00000000000..eaabddc3b3c --- /dev/null +++ b/packages/flame_flutter3d/pubspec.yaml @@ -0,0 +1,90 @@ +name: flame_flutter3d +description: "A bridge to the Flame 2D game engine: Flame draws its own layer, flutter3d draws its own, and the two stay reconciled — transforms, lifecycle, physics contacts, input and the actor system." +version: 0.9.0-dev.0 +homepage: https://github.com/flame-engine/flame/tree/main/packages/flame_flutter3d +funding: + - https://opencollective.com/blue-fire + - https://github.com/sponsors/bluefireteam + - https://patreon.com/bluefireoss +topics: + - game-development + - game-engine + - graphics + - flame +resolution: workspace + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" + +dependencies: + # Flame renders its own layer; nothing here reimplements its component + # tree, its camera, or its collision broadphase. + flame: ^2.0.0-dev.0 + + flutter: + sdk: flutter + + # The scene graph a Flame component's transform is bridged onto. + flutter3d: ^0.8.3 + + # The surface flutter3d itself draws through, and the clock every host in + # this repo already ticks a scene with. + flutter3d_app: ^0.8.1 + + # `Bindings`, and the desktop/pad input translators the input bridge's own + # guide points a reader back to rather than duplicating. + flutter3d_game: ^0.8.1 + + # The pool and the instanced draw a Flame game's blasts go through. Plain + # Dart, no native code: a game that throws no particles pays nothing. + flutter3d_particles: ^0.8.1 + + # Rigid bodies and the collision world the physics bridge steps and + # listens to. + flutter3d_physics: ^0.8.2 + + # The actor/entity system the ECS bridge wraps, and the shared `InputState` + # the input bridge writes into — the same object `flutter3d_game`'s own + # `DesktopInput`/`PadInput` write into, so a bridged game and a native one + # read one input model. + flutter3d_sim: ^0.8.1 + + # The map a Tiled level is read into, the one `flame_tiled` reads too: a + # game that loads its level through `TiledComponent` hands over its + # `tileMap.map`. Plain Dart and XML, no rendering. + tiled: ^0.12.0 + + vector_math: ^2.2.0 + +dev_dependencies: + # Flame's own 2D physics, for the test that a bridged component follows + # one of its bodies; the bridge itself asks only for Flame's providers. + flame_forge2d: ^0.21.0-dev.0 + + flame_lint: ^1.4.4-dev.0 + + # Flame's own test harness: a `GameWidget` under `flutter_test` needs its + # first load/resize driven a specific way plain `pumpWidget`/`pump` does + # not do on their own. + flame_test: ^3.0.0-dev.0 + + # A renderer with no GPU under it, so a bridge test can build a scene and + # pump a frame without a device. + flutter3d_cpu: ^0.8.0 + + # A runner that climbs, for the character body's own test. + flutter3d_game_platformer: ^0.8.0 + + # A device that records rather than draws, to see what a component gave + # back when it went. + flutter3d_hardware: ^0.8.0 + + flutter_test: + sdk: flutter + + # A controller that is not there, for the pad feed's own test. + pad_input: ^0.4.3 + +flutter: + uses-material-design: true diff --git a/packages/flame_flutter3d/test/actor_component_test.dart b/packages/flame_flutter3d/test/actor_component_test.dart new file mode 100644 index 00000000000..4eb6b076232 --- /dev/null +++ b/packages/flame_flutter3d/test/actor_component_test.dart @@ -0,0 +1,157 @@ +/// [ActorComponent] keeps a Flame position in step with the body a real +/// flutter3d_sim [Actor] simulates. +library; + +import 'package:flame_flutter3d/src/ecs/actor_component.dart'; +import 'package:flame_flutter3d/src/ecs/actor_system_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart' hide Plane; + +ActorSystem _system() => + ActorSystem(world: CollisionWorld(), random: GameRandom(1)); + +void main() { + test('copies the actor body position onto the Flame position each frame', () { + final scene = Scene(); + final node = SceneNode(); + final system = _system(); + final body = CharacterController(world: system.world); + final actor = system.spawn(body: body); + + final component = ActorComponent( + actor: actor, + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + body.position.setValues(3.0, 0.0, 4.0); + component.update(1 / 60); + + expect(component.position, Vector2(3.0, 4.0)); + // The node was updated too, not only the Flame side — the body's + // position reaches it through `node`, not around it. + expect(node.readPosition(), Vector3(3.0, 0.0, 4.0)); + }); + + test('leaves the Flame position untouched when the actor has no body', () { + final scene = Scene(); + final node = SceneNode()..setPosition(1.0, 0.0, 2.0); + final system = _system(); + final actor = system.spawn(); + + final component = ActorComponent( + actor: actor, + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + component.update(1 / 60); + + expect(component.position, Vector2(1.0, 2.0)); + }); + + test('onRemove detaches the node without throwing after despawn', () { + final scene = Scene(); + final node = SceneNode(); + final system = _system(); + final body = CharacterController(world: system.world); + final actor = system.spawn(body: body); + + final component = ActorComponent( + actor: actor, + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + system.remove(actor); + expect(actor.exists, isFalse); + + expect(component.onRemove, returnsNormally); + expect(node.parent, isNull); + }); + + test('update does not throw once its actor has been despawned', () { + final scene = Scene(); + final node = SceneNode(); + final system = _system(); + final body = CharacterController(world: system.world); + final actor = system.spawn(body: body); + + final component = ActorComponent( + actor: actor, + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + system.remove(actor); + + // actor.body now reads null; update must treat that as "nothing to + // copy", the same as an actor that never had a body. + expect(() => component.update(1 / 60), returnsNormally); + }); + + test('turns the node the way the actor faces', () { + // An actor's yaw is radians about Y, nought looking down -Z; a quarter + // turn left looks down -X. Without it every bridged actor slid about + // facing the way it was built. + // + // Mutation: copy only the body's position. + final system = _system(); + final actor = system.spawn( + body: CharacterController(world: system.world), + facing: Facing(yaw: 1.5707963267948966), + ); + final component = ActorComponent( + actor: actor, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + )..onMount(); + component.update(1 / 60); + + final forward = component.node.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + expect(forward.x, closeTo(-1.0, 1e-6)); + expect(forward.z, closeTo(0.0, 1e-6)); + }); + + test('turns between its steps as it moves between them', () { + // Its place glided between two steps and its facing clicked round. + // + // Mutation: turn the node to the yaw the last step left. + final system = _system(); + final actor = system.spawn( + body: CharacterController(world: system.world), + facing: Facing(), + ); + final stepper = ActorSystemComponent(system: system, focus: Vector3.zero); + final component = ActorComponent( + actor: actor, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + stepper: stepper, + )..onMount(); + + component.rememberPlace(); + actor.facing!.yaw = 1.5707963267948966; + stepper.step.advance(1 / 60 + 1 / 120); + expect(stepper.alpha, closeTo(0.5, 1e-9)); + component.update(0.0); + + final forward = component.node.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + // Half of a quarter turn left: an eighth, between -Z and -X. + expect(forward.x, closeTo(-0.7071, 1e-3)); + expect(forward.z, closeTo(-0.7071, 1e-3)); + }); +} diff --git a/packages/flame_flutter3d/test/actor_system_component_test.dart b/packages/flame_flutter3d/test/actor_system_component_test.dart new file mode 100644 index 00000000000..76522e20286 --- /dev/null +++ b/packages/flame_flutter3d/test/actor_system_component_test.dart @@ -0,0 +1,147 @@ +/// [ActorSystemComponent] steps a real flutter3d_sim [ActorSystem] exactly +/// once per step, through the `beginStep`/`step` pair the system +/// requires. +library; + +import 'package:flame_flutter3d/src/ecs/actor_system_component.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +ActorSystem _system() => + ActorSystem(world: CollisionWorld(), random: GameRandom(1)); + +void main() { + test('update actually steps the system, advancing a body under gravity', () { + final system = _system(); + final body = CharacterController( + world: system.world, + position: Vector3(0.0, 10.0, 0.0), + ); + system.spawn(body: body); + + final component = ActorSystemComponent( + system: system, + focus: Vector3.zero, + ); + + final before = body.position.y; + component.update(1 / 60); + + // Nothing below the body to stand on, so one step of gravity must have + // moved it — proof that `step` actually ran, not just `beginStep`. + expect(body.position.y, lessThan(before)); + }); + + test( + 'update can be called every frame without tripping the beginStep contract', + () { + final system = _system(); + final component = ActorSystemComponent( + system: system, + focus: Vector3.zero, + ); + + // ActorSystem.step throws a StateError when called without a matching + // beginStep first. Each ActorSystemComponent.update does both, in + // order, so five frames in a row must be as unremarkable as one. + expect(() { + for (var i = 0; i < 5; i++) { + component.update(1 / 60); + } + }, returnsNormally); + }, + ); + + test( + 'a bare step() right after update() still hits the beginStep contract', + () { + final system = _system(); + final component = ActorSystemComponent( + system: system, + focus: Vector3.zero, + ); + + // update() already consumed this frame's begin/step pair. A second, + // independent call to step() must find the system exactly as any other + // caller would: not yet begun for a step of its own. + component.update(1 / 60); + + expect( + () => system.step(1 / 60, focus: Vector3.zero()), + throwsStateError, + ); + }, + ); + + test('focus and focusBody are read fresh every update, not cached', () { + final system = _system(); + var focusCalls = 0; + var focusBodyCalls = 0; + final component = ActorSystemComponent( + system: system, + focus: () { + focusCalls++; + return Vector3(focusCalls.toDouble(), 0.0, 0.0); + }, + focusBody: () { + focusBodyCalls++; + return null; + }, + ); + + component.update(1 / 60); + component.update(1 / 60); + + expect(focusCalls, 2); + expect(focusBodyCalls, 2); + expect(system.focus, Vector3(2.0, 0.0, 0.0)); + }); + + test('focusBody is optional', () { + final system = _system(); + final component = ActorSystemComponent( + system: system, + focus: Vector3.zero, + ); + + expect(() => component.update(1 / 60), returnsNormally); + }); + + test('takes its priority at construction, like the physics stepper', () { + // A game orders the actor step before the physics step and both before + // their readers; a cascade after the constructor was the only way to + // say so for this one. + final component = ActorSystemComponent( + system: _system(), + focus: Vector3.zero, + priority: -120, + ); + + expect(component.priority, -120); + }); + + test('a second of play moves an actor as far at any frame rate', () { + // Mutation: step the system by the frame's own dt. + double after(double frame) { + final system = _system(); + final body = CharacterController( + world: system.world, + position: Vector3(0.0, 10.0, 0.0), + ); + system.spawn(body: body); + final component = ActorSystemComponent( + system: system, + focus: Vector3.zero, + ); + for (var t = 0; t < (1.0 / frame).round(); t++) { + component.update(frame); + } + return body.position.y; + } + + final slow = after(1 / 30); + expect(slow, lessThan(10.0), reason: 'it never fell'); + expect(after(1 / 120), closeTo(slow, 1e-9)); + }); +} diff --git a/packages/flame_flutter3d/test/atmosphere_component_test.dart b/packages/flame_flutter3d/test/atmosphere_component_test.dart new file mode 100644 index 00000000000..6ca058cf61c --- /dev/null +++ b/packages/flame_flutter3d/test/atmosphere_component_test.dart @@ -0,0 +1,49 @@ +/// A day on Flame's clock, on the game's 3D world. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d {} + +void main() { + test('the day turns, and its air is on the scene and in the sky', () async { + final game = _World()..open3d(FakeBackend()); + await initializeGame(() => game); + final sun = LightNode(); + final day = AtmosphereComponent( + cycle: AtmosphereCycle(<(double, Atmosphere)>[ + ( + 0.0, + Atmosphere(sky: Vector3(0.4, 0.6, 0.9), sunColor: Vector3.all(1.0)), + ), + ( + 10.0, + Atmosphere( + sky: Vector3(0.0, 0.0, 0.1), + fogDensity: 0.02, + sunColor: Vector3.all(0.2), + sunIntensity: 0.1, + ), + ), + ], period: 20.0), + sun: sun, + ); + game.add(day); + await game.ready(); + + game.update(10.0); + expect(game.clearColor.z, closeTo(0.1, 1e-6)); + expect(sun.intensity, closeTo(0.1, 1e-6)); + expect(day.fog.density, closeTo(0.02, 1e-6)); + // The frame is drawn through the day's fog without the game reading it + // across by hand. + // + // Mutation: leave the fog to the game's own settings. + expect(game.renderSettings().fog.density, closeTo(0.02, 1e-6)); + }); +} diff --git a/packages/flame_flutter3d/test/bridge_clock_test.dart b/packages/flame_flutter3d/test/bridge_clock_test.dart new file mode 100644 index 00000000000..29c38a74f14 --- /dev/null +++ b/packages/flame_flutter3d/test/bridge_clock_test.dart @@ -0,0 +1,57 @@ +/// [BridgeClock] runs after every sibling component's own update, regardless +/// of which of the two was added to the [FlameGame] first. +/// +/// This is the guarantee [Flutter3dFlameWidget]'s own doc comment relies on, +/// and it does not hold by insertion order alone: on the path where this +/// widget opens its own `GraphicsDevice`, [BridgeClock] is added from the +/// widget's first `build`, before `buildScene` has run and before the host's +/// own components exist to share an insertion order with. Only an explicit +/// priority, set once in [BridgeClock]'s own constructor, makes the order the +/// same either way — which is what these two tests, ordered oppositely, both +/// check. +library; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show Flutter3dFlameWidget; +import 'package:flame_flutter3d/src/host/bridge_clock.dart'; +import 'package:flame_flutter3d/src/host/flutter3d_flame_widget.dart' + show Flutter3dFlameWidget; +import 'package:flutter_test/flutter_test.dart'; + +final class _RecordingComponent extends Component { + _RecordingComponent(this.log, this.name); + + final List log; + final String name; + + @override + void update(double dt) { + super.update(dt); + log.add(name); + } +} + +void main() { + test('BridgeClock added before its sibling still runs after it', () { + final log = []; + final game = FlameGame() + ..add(BridgeClock(onTick: (double dt) => log.add('clock'))) + ..add(_RecordingComponent(log, 'sibling')); + + game.update(1 / 60); + + expect(log, ['sibling', 'clock']); + }); + + test('BridgeClock added after its sibling still runs after it', () { + final log = []; + final game = FlameGame() + ..add(_RecordingComponent(log, 'sibling')) + ..add(BridgeClock(onTick: (double dt) => log.add('clock'))); + + game.update(1 / 60); + + expect(log, ['sibling', 'clock']); + }); +} diff --git a/packages/flame_flutter3d/test/camera_and_projection_test.dart b/packages/flame_flutter3d/test/camera_and_projection_test.dart new file mode 100644 index 00000000000..4c769854078 --- /dev/null +++ b/packages/flame_flutter3d/test/camera_and_projection_test.dart @@ -0,0 +1,207 @@ +/// A chase camera behind a bridged component, the projection between the 3D +/// camera and Flame's screen, and the input a phone's stick and button +/// feed. +library; + +import 'package:flame/components.dart'; +import 'package:flame/input.dart' show HudButtonComponent; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Object3dComponent _jet(Vector2 at) { + final jet = Object3dComponent( + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + elevation: 1.7, + position: at, + )..onMount(); + return jet..updateTree(0.0); +} + +void main() { + group('ChaseCamera', () { + test('sits at the offset and looks at the look offset', () { + final camera = CameraNode(); + final jet = _jet(Vector2(4.0, -20.0)); + ChaseCamera( + camera: camera, + target: jet, + offset: Vector3(0.0, 11.0, 11.0), + lookOffset: Vector3(0.0, -1.7, -9.0), + followAcross: 0.35, + lookAcross: 0.5, + ).advance(1 / 60); + + final eye = camera.readPosition(); + expect(eye.x, closeTo(4.0 * 0.35, 1e-6)); + expect(eye.y, closeTo(1.7 + 11.0, 1e-6)); + expect(eye.z, closeTo(-20.0 + 11.0, 1e-6)); + // Looking at (2, 0, -29): the forward axis points there from the eye. + // Through the rotation matrix, the one a node is drawn with: `rotated` + // turns the other way (see `BridgePlane.rotationFor`). + final forward = camera.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + final wanted = (Vector3(2.0, 0.0, -29.0) - eye)..normalize(); + expect(forward.dot(wanted), closeTo(1.0, 1e-5)); + }); + + test('stiff, it keeps up with the target frame by frame', () { + final camera = CameraNode(); + final jet = _jet(Vector2.zero()); + final chase = ChaseCamera( + camera: camera, + target: jet, + offset: Vector3(0.0, 10.0, 10.0), + lookOffset: Vector3.zero(), + )..advance(1 / 60); + jet + ..position.y = -3.0 + ..updateTree(0.0); + chase.advance(1 / 60); + expect(camera.readPosition().z, closeTo(7.0, 1e-4)); + }); + + test('a shake moves the camera off its place, and dies away', () { + final camera = CameraNode(); + final chase = ChaseCamera( + camera: camera, + target: _jet(Vector2.zero()), + offset: Vector3(0.0, 10.0, 10.0), + lookOffset: Vector3.zero(), + )..advance(1 / 60); + chase.rig.shake(0.5); + chase.advance(1 / 60); + final shaken = camera.readPosition()..sub(Vector3(0.0, 11.7, 10.0)); + expect(shaken.length, greaterThan(1e-3)); + for (var i = 0; i < 180; i++) { + chase.advance(1 / 60); + } + final settled = camera.readPosition()..sub(Vector3(0.0, 11.7, 10.0)); + expect(settled.length, lessThan(1e-3)); + }); + + test('with stiffness it closes on the place instead of jumping', () { + final camera = CameraNode(); + final jet = _jet(Vector2.zero()); + final chase = ChaseCamera( + camera: camera, + target: jet, + offset: Vector3(0.0, 10.0, 10.0), + lookOffset: Vector3.zero(), + stiffness: 4.0, + )..advance(1 / 60); + expect(camera.readPosition().z, closeTo(10.0, 1e-6), reason: 'placed'); + + jet + ..position.y = -10.0 + ..updateTree(0.0); + chase.advance(0.1); + final z = camera.readPosition().z; + expect(z, lessThan(10.0)); + expect(z, greaterThan(0.0), reason: 'not there in one tenth of a second'); + }); + }); + + group('BridgeProjector', () { + late CameraNode camera; + late BridgeProjector projector; + + setUp(() { + camera = CameraNode() + ..setPosition(0.0, 12.0, 10.0) + ..lookAt(Vector3(0.0, 0.0, -5.0)); + projector = BridgeProjector( + camera: camera, + viewSize: () => Vector2(800.0, 600.0), + ); + }); + + test('a point on the plane goes to the screen and comes back', () { + final plane = BridgePlane.ground(); + final point = Vector2(3.0, -8.0); + final screen = projector.toScreen(plane.to3d(point))!; + expect(screen.x, greaterThan(400.0), reason: 'right of centre'); + final back = projector.onPlane(screen, plane)!; + expect(back.x, closeTo(point.x, 1e-3)); + expect(back.y, closeTo(point.y, 1e-3)); + }); + + test('the point looked at is the middle of the screen', () { + final screen = projector.toScreen(Vector3(0.0, 0.0, -5.0))!; + expect(screen.x, closeTo(400.0, 1e-3)); + expect(screen.y, closeTo(300.0, 1e-3)); + }); + + test('behind the camera, and the sky, have no answer', () { + expect(projector.toScreen(Vector3(0.0, 12.0, 30.0)), isNull); + // The top edge of a camera looking down at 45 degrees or so still + // meets the ground; the top of one looking at the horizon does not. + final level = CameraNode() + ..setPosition(0.0, 2.0, 0.0) + ..lookAt(Vector3(0.0, 2.0, -10.0)); + final sky = BridgeProjector( + camera: level, + viewSize: () => Vector2(800.0, 600.0), + ).onPlane(Vector2(400.0, 10.0), BridgePlane.ground()); + expect(sky, isNull); + }); + }); + + group('phone input', () { + FlameInputBridge bridge() => FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + + test("the stick's deflection is the move axis, screen-up forward", () { + final input = bridge(); + final stick = JoystickComponent( + knob: CircleComponent(radius: 10.0), + background: CircleComponent(radius: 40.0), + ); + final feed = input.followJoystick(stick); + stick.delta.setValues(stick.knobRadius, -stick.knobRadius); + feed.update(1 / 60); + // Right and up the screen at once: a diagonal, which the axis normalises. + expect(input.inputState.moveAxis.x, closeTo(0.7071, 1e-3)); + expect(input.inputState.moveAxis.y, greaterThan(0.0)); + }); + + test('a bound button holds its action while pressed', () { + final input = bridge(); + const fire = GameAction('fire'); + final button = HudButtonComponent(button: CircleComponent(radius: 10.0)); + input.bindButton(button, fire); + + button.onPressed!(); + expect(input.inputState.held(fire), isTrue); + button.onReleased!(); + expect(input.inputState.held(fire), isFalse); + button.onPressed!(); + button.onCancelled!(); + expect(input.inputState.held(fire), isFalse); + }); + }); + + test('a point behind an orthographic camera is drawn nowhere', () { + // An orthographic projection does not divide by depth, and a point + // behind the camera came back drawn as if it were in front. + // + // Mutation: ask only the projection. + final eye = CameraNode(projection: const OrthographicProjection(height: 20)) + ..setPosition(0.0, 10.0, 0.0) + ..lookAt(Vector3(0.0, 0.0, 0.0001)); + final projector = BridgeProjector( + camera: eye, + viewSize: () => Vector2(200.0, 200.0), + ); + expect(projector.toScreen(Vector3(1.0, 0.0, 1.0)), isNotNull); + expect(projector.toScreen(Vector3(1.0, 20.0, 1.0)), isNull); + }); +} diff --git a/packages/flame_flutter3d/test/camera_sync_component_test.dart b/packages/flame_flutter3d/test/camera_sync_component_test.dart new file mode 100644 index 00000000000..dfbe690a1cf --- /dev/null +++ b/packages/flame_flutter3d/test/camera_sync_component_test.dart @@ -0,0 +1,148 @@ +/// A [CameraSyncComponent] advances its [CameraSyncController] once per +/// Flame update. +library; + +import 'package:flame/camera.dart' show Viewfinder; +import 'package:flame/components.dart' show PositionComponent; +import 'package:flame/experimental.dart' show Rectangle; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('an update carries the authoritative side across', () { + final camera = CameraNode()..setPosition(3.0, 0.0, 4.0); + final viewfinder = Viewfinder(); + final component = CameraSyncComponent( + controller: CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + ), + priority: 10, + ); + + component.update(1 / 60); + + expect(viewfinder.position, Vector2(3.0, 4.0)); + expect(component.priority, 10); + }); + + testWithGame( + 'synced from a viewfinder that follows the player, the 3D camera is ' + 'where the player is this frame', + FlameGame.new, + (game) async { + // Flame's camera follows its target after everything else, and a sync + // run before it read last frame's viewfinder. + // + // Mutation: give the flowing-to-the-scene sync the camera priority. + final camera = CameraNode(); + final player = _Runner(); + game.world.add(player); + game.add( + CameraSyncComponent( + controller: CameraSyncController( + camera: camera, + viewfinder: game.camera.viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + ), + ), + ); + game.camera.follow(player); + await game.ready(); + + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + } + expect(camera.readPosition().x, closeTo(player.position.x, 1e-6)); + }, + ); + + testWithGame( + 'added to the world, where a game adds its components, it is still ' + 'where the player is this frame', + FlameGame.new, + (game) async { + // A priority orders siblings only. Inside the world it ran before + // Flame's camera, which is the world's sibling, whatever its number, + // and the 3D camera trailed `camera.follow()` by a frame again. + // + // Mutation: advance the controller from this component's own update. + final camera = CameraNode(); + final player = _Runner(); + game.world.add(player); + game.world.add( + CameraSyncComponent( + controller: CameraSyncController( + camera: camera, + viewfinder: game.camera.viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + ), + ), + ); + game.camera.follow(player); + await game.ready(); + + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + } + expect(camera.readPosition().x, closeTo(player.position.x, 1e-6)); + }, + ); + + testWithGame( + "Flame's follow at a top speed, and its bounds, move a perspective " + 'camera', + FlameGame.new, + (game) async { + // Mutation: sync a perspective camera by position alone. + final camera = CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.9), + ); + final player = _Jumper(); + game.world.add(player); + game.add( + CameraSyncComponent( + controller: CameraSyncController( + camera: camera, + viewfinder: game.camera.viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + eyeOffset: Vector3(0.0, 10.0, 8.0), + ), + ), + ); + game.camera.follow(player, maxSpeed: 60.0); + await game.ready(); + + player.position.x = 100.0; + game.update(1 / 60); + final eye = camera.readPosition(); + expect(eye.x, closeTo(1.0, 1e-6), reason: 'a metre a frame at most'); + expect(eye.y, closeTo(10.0, 1e-6), reason: 'up where it looks from'); + + game.camera.stop(); + game.camera.setBounds(Rectangle.fromLTRB(-5.0, -5.0, 5.0, 5.0)); + game.camera.moveTo(Vector2(40.0, 0.0)); + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + } + expect(camera.readPosition().x, closeTo(5.0, 1e-6)); + }, + ); +} + +final class _Jumper extends PositionComponent {} + +final class _Runner extends PositionComponent { + @override + void update(double dt) { + super.update(dt); + position.x += 10.0; + } +} diff --git a/packages/flame_flutter3d/test/camera_sync_controller_test.dart b/packages/flame_flutter3d/test/camera_sync_controller_test.dart new file mode 100644 index 00000000000..79e933883f4 --- /dev/null +++ b/packages/flame_flutter3d/test/camera_sync_controller_test.dart @@ -0,0 +1,265 @@ +/// A [CameraSyncController] keeps a flutter3d [CameraNode] and a Flame +/// [Viewfinder] describing the same view, on whichever side [SyncDirection] +/// names as authoritative. +library; + +import 'package:flame/camera.dart' show Viewfinder; +import 'package:flame_flutter3d/src/camera/camera_sync_controller.dart'; +import 'package:flame_flutter3d/src/transform/object3d_component.dart' + show SyncDirection; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart' hide Plane; + +void main() { + test('sceneToFlame moves the viewfinder position to the camera, through the ' + 'plane', () { + final camera = CameraNode()..setPosition(3.0, 0.0, 4.0); + final viewfinder = Viewfinder(); + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + ); + + controller.advance(1 / 60); + + expect(viewfinder.position, Vector2(3.0, 4.0)); + }); + + test('sceneToFlame moves the viewfinder zoom to the orthographic height, ' + 'reciprocally', () { + final camera = CameraNode( + projection: const OrthographicProjection(height: 4.0), + ); + final viewfinder = Viewfinder(); + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + ); + + controller.advance(1 / 60); + + expect(viewfinder.zoom, 0.25); + }); + + test( + 'sceneToFlame leaves the viewfinder zoom alone for a perspective camera', + () { + final camera = CameraNode(projection: const PerspectiveProjection()); + final viewfinder = Viewfinder()..zoom = 2.0; + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + ); + + controller.advance(1 / 60); + + expect(viewfinder.zoom, 2.0); + }, + ); + + test('flameToScene moves the camera position to the viewfinder, through the ' + 'plane', () { + final camera = CameraNode(); + final viewfinder = Viewfinder()..position = Vector2(5.0, 6.0); + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(height: 1.5), + direction: SyncDirection.flameToScene, + ); + + controller.advance(1 / 60); + + final read = camera.readPosition(); + expect(read.x, 5.0); + expect(read.y, 1.5); + expect(read.z, 6.0); + }); + + test('flameToScene moves the orthographic height to the viewfinder zoom, ' + 'reciprocally', () { + final camera = CameraNode( + projection: const OrthographicProjection(height: 4.0), + ); + final viewfinder = Viewfinder()..zoom = 0.5; + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + ); + + controller.advance(1 / 60); + + final projection = camera.projection; + expect(projection, isA()); + expect((projection as OrthographicProjection).height, 2.0); + }); + + test('flameToScene leaves a perspective projection alone', () { + const projection = PerspectiveProjection(); + final camera = CameraNode(projection: projection); + final viewfinder = Viewfinder()..zoom = 2.0; + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + ); + + controller.advance(1 / 60); + + expect(camera.projection, same(projection)); + }); + + test("with the viewport's height, the two lenses agree to the pixel", () { + // A 256-unit-tall field in a 512-pixel viewport is two pixels a unit in + // both engines, and a Flame zoom of 4 is a 128-unit-tall view. + // + // Mutation: keep the reciprocal convention when a height is given. + final camera = CameraNode( + projection: const OrthographicProjection(height: 256.0), + ); + final viewfinder = Viewfinder(); + CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + viewportHeight: () => 512.0, + ).advance(0.0); + expect(viewfinder.zoom, closeTo(2.0, 1e-9)); + + viewfinder.zoom = 4.0; + CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + viewportHeight: () => 512.0, + ).advance(0.0); + expect( + (camera.projection as OrthographicProjection).height, + closeTo(128.0, 1e-9), + ); + }); + + test("a rolling screen rolls the camera about the plane's normal, and " + 'reads back', () { + // Mutation: leave the camera's rotation alone when the angle moves. + final camera = CameraNode()..lookAt(Vector3(0.0, -1.0, -0.001)); + final reader = CameraSyncController( + camera: camera, + viewfinder: Viewfinder(), + plane: BridgePlane.ground(), + syncAngle: true, + ); + CameraSyncController( + camera: camera, + viewfinder: Viewfinder()..angle = 0.4, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + syncAngle: true, + ).advance(0.0); + + reader.advance(0.0); + expect(reader.viewfinder.angle, closeTo(0.4, 1e-5)); + }); + + test("with an eye offset, a perspective camera looks at the viewfinder's " + 'point from there, nearer as it zooms and round as it turns', () { + // Put at the viewfinder's point, on the plane, a perspective camera + // looked at nothing Flame's camera did. + // + // Mutation: ignore the offset. + final camera = CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.9), + ); + final viewfinder = Viewfinder() + ..position = Vector2(3.0, -4.0) + ..zoom = 2.0; + final controller = CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + eyeOffset: Vector3(0.0, 12.0, 10.0), + syncAngle: true, + )..advance(0.0); + + final eye = camera.readPosition(); + expect(eye.x, closeTo(3.0, 1e-5)); + expect(eye.y, closeTo(6.0, 1e-5)); + expect(eye.z, closeTo(1.0, 1e-5)); + final forward = camera.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + final toTarget = (Vector3(3.0, 0.0, -4.0) - eye)..normalize(); + expect(forward.dot(toTarget), closeTo(1.0, 1e-5)); + + // A quarter turn of the viewfinder takes the eye round the point. + viewfinder.angle = 1.5707963267948966; + controller.advance(0.0); + final turned = camera.readPosition(); + expect(turned.distanceTo(Vector3(3.0, 0.0, -4.0)), closeTo(7.8102, 1e-3)); + expect((turned.x - 3.0).abs(), closeTo(5.0, 1e-4)); + }); + + test('an orthographic camera given an offset looks along it, and zooms by ' + 'its height', () { + // An isometric board: the camera from a corner, the zoom the lens. + // + // Mutation: ignore the offset under an orthographic lens. + final camera = CameraNode( + projection: const OrthographicProjection(height: 10.0), + ); + final viewfinder = Viewfinder() + ..position = Vector2(2.0, -2.0) + ..zoom = 0.5; + CameraSyncController( + camera: camera, + viewfinder: viewfinder, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + eyeOffset: Vector3(10.0, 10.0, 10.0), + ).advance(0.0); + + final eye = camera.readPosition(); + expect(eye.x, closeTo(12.0, 1e-5), reason: 'not nearer for the zoom'); + expect(eye.y, closeTo(10.0, 1e-5)); + expect(eye.z, closeTo(8.0, 1e-5)); + final forward = camera.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + expect(forward.x, closeTo(forward.y, 1e-5), reason: 'down the diagonal'); + expect(forward.y, closeTo(forward.z, 1e-5)); + expect( + (camera.projection as OrthographicProjection).height, + closeTo(2.0, 1e-9), + ); + }); + + test('a camera aimed after the controller was made rests where it was ' + 'aimed, once told', () { + // The rest was the rotation at construction, and a camera pointed with + // lookAt afterwards read as rolled by the difference. + // + // Mutation: make takeRest do nothing. + final camera = CameraNode()..setPosition(0.0, 10.0, 0.0); + final controller = CameraSyncController( + camera: camera, + viewfinder: Viewfinder(), + plane: BridgePlane.ground(), + syncAngle: true, + ); + camera.lookAt(Vector3(-5.0, 10.0, -5.0)); + controller + ..takeRest() + ..advance(0.0); + expect(controller.viewfinder.angle, closeTo(0.0, 1e-5)); + }); +} diff --git a/packages/flame_flutter3d/test/cell_grid_component_test.dart b/packages/flame_flutter3d/test/cell_grid_component_test.dart new file mode 100644 index 00000000000..e3691b5aa4b --- /dev/null +++ b/packages/flame_flutter3d/test/cell_grid_component_test.dart @@ -0,0 +1,68 @@ +/// A shield in the game: hit where a block is, it wears away and is drawn +/// again; through a hole, the shot passes. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d {} + +void main() { + test('a hit on a block wears it away, and a hole lets a shot by', () async { + // Mutation: report a hit wherever the shield's box is. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final shield = CellGridComponent( + grid: CellGrid.fromMask(['####', '#..#']), + device: device, + scene: game.scene, + plane: BridgePlane.ground(), + material: engine.Material(), + position: Vector2(10.0, -5.0), + ); + game.add(shield); + await game.ready(); + expect(shield.size, Vector2(4.0, 2.0)); + + // The hole is the middle of the bottom row: one metre in, one and a + // half down from the corner. + expect(shield.hitAt(Vector2(11.5, -3.5), radius: 0.4), isFalse); + expect(device.releasedGeometry, isEmpty); + + expect(shield.hitAt(Vector2(10.5, -4.5), radius: 0.4), isTrue); + expect(shield.grid.isAlive(0, 0), isFalse); + expect(device.releasedGeometry, isNotEmpty, reason: 'the old mesh went'); + }); + + test('a removed shield gives back the mesh it was standing in', () async { + // Mutation: let go of meshes only on a hit. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final shield = CellGridComponent( + grid: CellGrid.fromMask(['##']), + device: device, + scene: game.scene, + plane: BridgePlane.ground(), + material: engine.Material(), + ); + game.add(shield); + await game.ready(); + final standing = shield.node.childrenView.whereType().single; + + shield.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + final mesh = standing.mesh as DeviceMesh; + expect( + device.releasedGeometry, + containsAll([mesh.vertices, mesh.indices]), + ); + }); +} diff --git a/packages/flame_flutter3d/test/character_body_component_test.dart b/packages/flame_flutter3d/test/character_body_component_test.dart new file mode 100644 index 00000000000..4c4dfd6814c --- /dev/null +++ b/packages/flame_flutter3d/test/character_body_component_test.dart @@ -0,0 +1,107 @@ +/// A platformer's runner, moved by its own rules and seen by Flame. +library; + +import 'package:flame/components.dart' show Component, PositionComponent; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Side extends FlameGame with HasFixedStep {} + +void main() { + testWithGame<_Side>( + 'a runner on a ladder climbs it, and Flame sees it go up the screen', + _Side.new, + (game) async { + // Mutation: never call drive; the body stays where it began. + final world = CollisionWorld(); + world.add( + Collider( + shape: CollisionBox(Vector3(20.0, 0.5, 20.0)), + position: Vector3(0.0, -0.5, 0.0), + ), + ); + final body = CharacterController( + world: world, + position: Vector3(0.0, 0.9, 0.0), + ); + final runner = Runner(body: body); + Climbable( + collider: world.add( + // As a level spawns one: a trigger, met by the player. + Collider( + shape: CollisionBox(Vector3(0.5, 4.0, 0.5)), + position: Vector3(0.0, 4.0, 0.0), + kind: ColliderKind.trigger, + layer: CollisionLayers.trigger, + mask: CollisionLayers.player, + ), + ), + ); + final input = InputState()..press(GameAction.moveForward); + + final harry = CharacterBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.backdrop(), + // What the platformer's own simulation does each step: the runner, + // then the world it moved in. + drive: (dt) { + runner.step(dt, input); + world.update(); + input.endStep(); + }, + ); + game.add(harry); + await game.ready(); + final startY = harry.position.y; + + for (var i = 0; i < 60; i++) { + game.update(1 / 60); + } + expect(runner.climbing, isNotNull, reason: 'it took hold'); + expect(body.position.y, greaterThan(2.0)); + expect(harry.position.y, lessThan(startY - 1.0), reason: 'up the screen'); + }, + ); + + testWithGame<_Side>( + 'a runner removed from the game leaves the world with removeFrom, and ' + 'stays in it when only moved', + _Side.new, + (game) async { + // Mutation: drop the removal from `onRemove`; the despawned runner + // stays in the world, solid and unseen. + final world = CollisionWorld(); + final body = CharacterController( + world: world, + position: Vector3(0.0, 0.9, 0.0), + ); + final harry = CharacterBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.backdrop(), + removeFrom: world, + ); + final shelf = PositionComponent(); + game.addAll([harry, shelf]); + await game.ready(); + + harry.parent = shelf; + await game.ready(); + await Future.delayed(Duration.zero); + expect(body.collider.world, same(world), reason: 'moved, not gone'); + + harry.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(body.collider.world, isNull); + }, + ); +} diff --git a/packages/flame_flutter3d/test/chunk_streamer_test.dart b/packages/flame_flutter3d/test/chunk_streamer_test.dart new file mode 100644 index 00000000000..c85e45b96af --- /dev/null +++ b/packages/flame_flutter3d/test/chunk_streamer_test.dart @@ -0,0 +1,54 @@ +/// The pieces of an endless world built as they come into view and let go +/// as they leave it. +library; + +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late List built; + late List<(int, String)> dropped; + late ChunkStreamer streamer; + + setUp(() { + built = []; + dropped = <(int, String)>[]; + streamer = ChunkStreamer( + build: (index) { + built.add(index); + return 'piece $index'; + }, + drop: (index, chunk) => dropped.add((index, chunk)), + ); + }); + + test('builds the window in order, and each piece once', () { + streamer + ..cover(2, 5) + ..cover(2, 5); + expect(built, [2, 3, 4, 5]); + expect(dropped, isEmpty); + expect(streamer[4], 'piece 4'); + expect(streamer[6], isNull); + }); + + test('moving on drops what fell behind and builds what came ahead', () { + streamer + ..cover(0, 3) + ..cover(2, 5); + expect(built, [0, 1, 2, 3, 4, 5]); + expect(dropped, <(int, String)>[(0, 'piece 0'), (1, 'piece 1')]); + expect(streamer.indices.toSet(), {2, 3, 4, 5}); + expect(streamer.chunks, hasLength(4)); + }); + + test('clear drops everything, and the next cover builds it afresh', () { + streamer + ..cover(0, 1) + ..clear(); + expect(dropped.map((d) => d.$1).toSet(), {0, 1}); + expect(streamer.chunks, isEmpty); + streamer.cover(0, 1); + expect(built, [0, 1, 0, 1]); + }); +} diff --git a/packages/flame_flutter3d/test/co_op_test.dart b/packages/flame_flutter3d/test/co_op_test.dart new file mode 100644 index 00000000000..24e099321d8 --- /dev/null +++ b/packages/flame_flutter3d/test/co_op_test.dart @@ -0,0 +1,371 @@ +/// What a co-op game with a simulation of its own asks of the bridge: +/// several foci, a step whose reports survive the game's own logic, actors +/// that come and go with the simulation, bodies the game moves drawn between +/// their steps, a horde in one draw, a party framed by one camera, players who +/// join by pressing, and a level that becomes the next. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/widgets.dart' show SizedBox, Widget; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Floor under everything, so bodies stand rather than fall. +ActorSystem _system() { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(40.0, 1.0, 40.0)) + ..update(); + return ActorSystem(world: world, random: GameRandom(1)); +} + +/// Walks at whatever it was given and remembers who that was. +final class _Chase extends Brain { + int attended = -1; + + @override + void act(Mind it) { + attended = it.focusIndex; + it.steerTowardsFocus(); + } +} + +/// A game that steps a simulation of its own in its fixed steps, and can be +/// told to do something in the middle of one. +final class _Game extends FlameGame with HasFixedStep { + void Function(double step)? logic; + + @override + void fixedUpdate(double step) => logic?.call(step); +} + +CpuDevice _device() => CpuDevice( + width: 8, + height: 8, + shaders: CpuShaderLibrary(builtinCpuShaders()), +); + +InstancedMeshNode _batch() => InstancedMeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + Material(), + capacity: 4, +); + +final class _World extends FlameGame with HasFlutter3d {} + +void main() { + test('several foci: each actor goes for the one it is nearest', () { + final system = _system(); + final west = _Chase(); + final east = _Chase(); + system + ..spawn( + body: CharacterController( + world: system.world, + position: Vector3(-3.0, 0.9, 0.0), + ), + brain: west, + ) + ..spawn( + body: CharacterController( + world: system.world, + position: Vector3(3.0, 0.9, 0.0), + ), + brain: east, + ); + final component = ActorSystemComponent( + system: system, + foci: () => [ + (at: Vector3(-8.0, 0.9, 0.0), body: null), + (at: Vector3(8.0, 0.9, 0.0), body: null), + ], + ); + + component.update(1 / 60); + + expect(west.attended, 0); + expect(east.attended, 1); + }); + + test('a focus and foci together are refused', () { + expect( + () => ActorSystemComponent( + system: _system(), + focus: Vector3.zero, + foci: () => const [], + ), + throwsA(isA()), + ); + }); + + testWithGame<_Game>( + "a death in the game's own logic survives the step it happened in", + _Game.new, + (game) async { + // Mutation: open the system's step just before the actors, as it was. + final system = _system(); + final victim = system.spawn( + body: CharacterController( + world: system.world, + position: Vector3(0.0, 0.9, 0.0), + ), + health: Health(10.0), + ); + game.add( + ActorSystemComponent(system: system, focus: Vector3.zero), + ); + await game.ready(); + game.logic = (double _) { + if (victim.isAlive) { + system.hurt(victim, 100.0); + } + }; + + game.update(1 / 60); + + expect(system.died, [victim]); + }, + ); + + testWithGame<_Game>( + 'an actor the simulation removes takes its component with it', + _Game.new, + (game) async { + final system = _system(); + final actor = system.spawn( + body: CharacterController(world: system.world), + ); + final component = ActorComponent( + actor: actor, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + ); + game.add(component); + await game.ready(); + + system.remove(actor); + game.update(1 / 60); + await game.ready(); + + expect(component.isMounted, isFalse); + }, + ); + + testWithGame<_Game>( + 'handed the system, removing the component removes the actor', + _Game.new, + (game) async { + final system = _system(); + final actor = system.spawn( + body: CharacterController(world: system.world), + ); + final component = ActorComponent( + actor: actor, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + removesFrom: system, + ); + game.add(component); + await game.ready(); + + component.removeFromParent(); + await game.ready(); + + expect(actor.exists, isFalse); + expect(system.actors, isEmpty); + }, + ); + + testWithGame<_Game>( + 'a body the game moves is drawn between where it was and where it is', + _Game.new, + (game) async { + // Mutation: keep the place in the component's own step, after the + // game's, as it was — then the drawn place is where the body is. + final world = CollisionWorld(); + final body = CharacterController( + world: world, + position: Vector3(0.0, 0.9, 0.0), + ); + final scene = Scene(); + final node = SceneNode(); + final component = CharacterBodyComponent( + body: body, + node: node, + scene: scene, + plane: BridgePlane.ground(), + stepper: game, + ); + game.add(component); + await game.ready(); + game.logic = (double _) => body.position.x += 1.0; + + // One step and half of the next. + game.update(1.5 / 60); + + expect(body.position.x, 1.0); + expect(node.readPosition().x, closeTo(0.5, 1e-6)); + }, + ); + + testWithGame<_Game>( + 'a horde is one batch: a slot per actor, given back when it goes', + _Game.new, + (game) async { + final system = _system(); + final batch = _batch(); + final actors = [ + for (var i = 0; i < 3; i++) + system.spawn( + body: CharacterController( + world: system.world, + position: Vector3(i * 2.0, 0.9, 0.0), + ), + ), + ]; + for (final actor in actors) { + game.add(InstancedActorComponent(actor: actor, batch: batch)); + } + await game.ready(); + game.update(1 / 60); + expect(batch.count, 3); + final placed = Matrix4.zero(); + batch.readTransform(2, placed); + expect(placed.getTranslation().x, closeTo(4.0, 1e-6)); + + system.remove(actors[1]); + game.update(1 / 60); + await game.ready(); + + expect(batch.count, 2); + }, + ); + + testWithGame<_Game>( + 'a pose component goes when its thing is gone', + _Game.new, + (game) async { + final batch = _batch(); + var there = true; + game.add( + InstancedPoseComponent( + batch: batch, + place: (Vector3 at) { + at.setValues(1.0, 2.0, 3.0); + return there; + }, + ), + ); + await game.ready(); + game.update(1 / 60); + expect(batch.count, 1); + + there = false; + game.update(1 / 60); + await game.ready(); + expect(batch.count, 0); + }, + ); + + test('a view camera goes where it is told, and stays when told nothing', () { + final camera = CameraNode(); + var told = true; + final view = ViewCamera( + camera: camera, + stiffness: 0.0, + view: (Vector3 eye, Vector3 target) { + if (!told) { + return false; + } + eye.setValues(0.0, 10.0, 5.0); + target.setValues(0.0, 0.0, 0.0); + return true; + }, + ); + + view.advance(1 / 60); + expect(camera.readPosition(), Vector3(0.0, 10.0, 5.0)); + + told = false; + camera.setPosition(9.0, 9.0, 9.0); + view.advance(1 / 60); + expect(camera.readPosition(), Vector3(9.0, 9.0, 9.0)); + }); + + test('seats: the first to claim is player one, and four is the limit', () { + FlameInputBridge bridge() => FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + final candidates = List.generate(6, (_) => bridge()); + final seats = PlayerSeats(candidates); + + expect(seats.claim(candidates[3]), isTrue); + expect(seats.claim(candidates[3]), isFalse, reason: 'already seated'); + expect(seats.claim(bridge()), isFalse, reason: 'not a candidate'); + seats + ..claim(candidates[0]) + ..claim(candidates[5]) + ..claim(candidates[1]); + expect(seats.claim(candidates[2]), isFalse, reason: 'four seated'); + expect(seats.seated, [ + candidates[3], + candidates[0], + candidates[5], + candidates[1], + ]); + expect(seats.free, [candidates[2], candidates[4]]); + + seats.release(candidates[0]); + expect(seats.seated.first, candidates[3]); + expect(seats.seated[1], candidates[5]); + }); + + testWidgets('keys reach the bridge whatever has the focus', ( + WidgetTester tester, + ) async { + // Mutation: feed the bridge only from the game's focus. + const fire = GameAction('fire'); + final input = FlameInputBridge( + bindings: Bindings({ + InputSource.key(LogicalKeyboardKey.space.keyId): fire, + }), + inputState: InputState(), + ); + final game = _Game(); + await tester.pumpWidget(GameWidget<_Game>(game: game) as Widget); + game.add(input.listenToKeyboard()); + await tester.pump(); + // Nothing in the tree has the focus: a game with no `KeyboardEvents` and + // nothing autofocused hears nothing through Flame. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget(GameWidget<_Game>(game: game) as Widget); + + await simulateKeyDownEvent(LogicalKeyboardKey.space); + expect(input.inputState.held(fire), isTrue); + await simulateKeyUpEvent(LogicalKeyboardKey.space); + expect(input.inputState.held(fire), isFalse); + }); + + test( + 'a level is a scene: the next one is drawn, through the same camera', + () { + final game = _World()..open3d(_device()); + final first = game.scene; + final next = Scene(); + + game.replaceScene3d(next); + + expect(game.scene, same(next)); + expect(next.cameras, contains(game.camera3d)); + expect(first.cameras, isNot(contains(game.camera3d))); + }, + ); +} diff --git a/packages/flame_flutter3d/test/collider_registry_test.dart b/packages/flame_flutter3d/test/collider_registry_test.dart new file mode 100644 index 00000000000..f434051c4ff --- /dev/null +++ b/packages/flame_flutter3d/test/collider_registry_test.dart @@ -0,0 +1,306 @@ +/// Which Flame component a collider belongs to, forgotten on its own when +/// the component leaves the game. +library; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show ActorSystem, GameRandom; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWithGame( + 'a collider is found while its component is in the game, and not after', + FlameGame.new, + (game) async { + // Mutation: keep the entry until it is unregistered by hand. + final registry = ColliderRegistry(); + final collider = Collider(shape: CollisionBox(Vector3.all(0.5))); + final bot = PositionComponent(); + game.add(bot); + await game.ready(); + registry.register(collider, bot); + expect(registry.componentFor(collider), same(bot)); + + bot.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(registry.componentFor(collider), isNull); + }, + ); + + testWithGame( + 'moved to another parent it is still found, and added again it is ' + 'found again', + FlameGame.new, + (game) async { + // Flame moves a component by removing and mounting it, and the + // removal dropped the entry for good; a pooled ship added back was + // never found either. + // + // Mutation: drop the entry on the first removal and never re-arm. + final registry = ColliderRegistry(); + final collider = Collider(shape: CollisionBox(Vector3.all(0.5))); + final bot = PositionComponent(); + final squad = PositionComponent(); + game.addAll([bot, squad]); + await game.ready(); + registry.register(collider, bot); + + bot.parent = squad; + await game.ready(); + await Future.delayed(Duration.zero); + expect(registry.componentFor(collider), same(bot), reason: 'moved'); + + bot.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(registry.componentFor(collider), isNull); + + game.add(bot); + await game.ready(); + await Future.delayed(Duration.zero); + expect(registry.componentFor(collider), same(bot), reason: 'back'); + + registry.unregister(collider); + bot.removeFromParent(); + await game.ready(); + game.add(bot); + await game.ready(); + await Future.delayed(Duration.zero); + expect( + registry.componentFor(collider), + isNull, + reason: 'unregistered stays unregistered', + ); + }, + ); + + testWithGame( + 'a partner removed mid-contact ends the contact on this side', + FlameGame.new, + (game) async { + // Flame's hitboxes end both sides when one goes; the world said + // nothing, and the ship went on colliding with a bot long gone. + // + // Mutation: end a contact only when the world reports it. + final world = CollisionWorld(); + final registry = ColliderRegistry(); + final body = RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + ); + final ship = _Ship(body, (_) {}); + final marker = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.2, 0.0, 0.0), + ), + ); + final bot = PositionComponent(); + game.addAll([ship, bot]); + await game.ready(); + registry + ..register(marker, bot) + ..bridge(collider: body.collider, component: ship); + + world.update(); + expect(ship.activeCollisions, contains(bot)); + + bot.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(ship.activeCollisions, isNot(contains(bot))); + expect(ship.isColliding, isFalse); + }, + ); + + testWithGame( + 'handed its stepper, a touch is told once a frame however many steps ' + 'the frame has', + FlameGame.new, + (game) async { + // Flame calls onCollision once a frame; the world reported it after + // every step, and a frame of three steps took three times the damage. + // + // Mutation: relay onCollision on every step. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final registry = ColliderRegistry(); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + mass: 0.0, + ), + ); + final ship = _Ship(body, (_) {}); + final marker = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.2, 0.0, 0.0), + ), + ); + final bot = PositionComponent(); + final stepper = PhysicsStepComponent(dynamics: dynamics, world: world); + game.addAll([stepper, ship, bot]); + await game.ready(); + registry + ..register(marker, bot) + ..bridge(collider: body.collider, component: ship, stepper: stepper); + + game.update(3 / 60); + expect(ship.touches, 1); + game.update(1 / 60); + expect(ship.touches, 2); + }, + ); + + testWithGame( + 'a ray across the plane finds the component it met, and where', + FlameGame.new, + (game) async { + // Flame's own raycast knows Flame's hitboxes and none of the level. + final world = CollisionWorld(); + final registry = ColliderRegistry(); + final crate = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(5.0, 0.0, -2.0), + ), + ); + final wall = world.add( + Collider( + shape: CollisionBox(Vector3(0.5, 2.0, 5.0)), + position: Vector3(9.0, 0.0, -2.0), + ), + ); + final bot = PositionComponent(); + game.add(bot); + await game.ready(); + registry.register(crate, bot); + + final plane = BridgePlane.ground(); + final hit = registry.raycast( + world, + plane, + Vector2(0.0, -2.0), + Vector2(20.0, -2.0), + )!; + expect(hit.component, same(bot)); + expect(hit.point.x, closeTo(4.5, 1e-6)); + expect(hit.point.y, closeTo(-2.0, 1e-6)); + + final past = registry.raycast( + world, + plane, + Vector2(0.0, -2.0), + Vector2(20.0, -2.0), + ignore: crate, + )!; + expect(past.collider, same(wall)); + expect(past.component, isNull, reason: 'the level is nobody'); + expect( + registry.raycast(world, plane, Vector2(0.0, 5.0), Vector2(20.0, 5.0)), + isNull, + ); + }, + ); + + testWithGame( + 'a bridge made through it hands over the other side of a contact', + FlameGame.new, + (game) async { + final world = CollisionWorld(); + final registry = ColliderRegistry(); + final body = RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + ); + PositionComponent? touched; + final ship = _Ship(body, (other) => touched = other); + final marker = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.2, 0.0, 0.0), + ), + ); + final bot = PositionComponent(); + game.addAll([ship, bot]); + await game.ready(); + registry + ..register(marker, bot) + ..bridge(collider: body.collider, component: ship); + + world.update(); + expect(touched, same(bot)); + }, + ); + + testWithGame( + 'an actor is told what its body touched, as a crate is', + FlameGame.new, + (game) async { + // Mutation: accept only a RigidBodyComponent as the bridged side. + final world = CollisionWorld(); + final system = ActorSystem(world: world, random: GameRandom(1)); + final body = CharacterController(world: world, position: Vector3.zero()); + PositionComponent? touched; + final bot = ActorComponent( + actor: system.spawn(body: body), + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + )..onCollisionStartCallback = (_, other) => touched = other; + final ship = PositionComponent(); + final hull = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.2, 0.0, 0.0), + ), + ); + game.addAll([bot, ship]); + await game.ready(); + ColliderRegistry() + ..register(hull, ship) + ..bridge(collider: body.collider, component: bot); + + world.update(); + expect(touched, same(ship)); + }, + ); +} + +final class _Ship extends RigidBodyComponent { + _Ship(RigidBody body, this.onTouch) + : super( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + ); + + final void Function(PositionComponent other) onTouch; + int touches = 0; + + @override + void onCollision(List intersectionPoints, PositionComponent other) { + super.onCollision(intersectionPoints, other); + touches++; + } + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + onTouch(other); + } +} diff --git a/packages/flame_flutter3d/test/collision_bridge_test.dart b/packages/flame_flutter3d/test/collision_bridge_test.dart new file mode 100644 index 00000000000..0ff0c8556a8 --- /dev/null +++ b/packages/flame_flutter3d/test/collision_bridge_test.dart @@ -0,0 +1,289 @@ +/// A [CollisionBridge] re-fires flutter3d's own [CollisionListener] events as +/// calls into a [RigidBodyComponent]'s Flame-side [CollisionCallbacks], and +/// stays silent when the caller's own registry has nothing bridged for the +/// other side. +library; + +import 'package:flame/collisions.dart' show CollisionCallbacks; +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/src/physics/collision_bridge.dart'; +import 'package:flame_flutter3d/src/physics/rigid_body_component.dart'; +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('fires onCollisionStart on the resolved component when two real ' + 'colliders overlap after world.update()', () { + final world = CollisionWorld(); + final scene = Scene(); + final plane = BridgePlane.ground(); + + final bodyA = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.0, 0.0, 0.0), + ); + final bodyB = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.2, 0.0, 0.0), + ); + + final componentA = RigidBodyComponent( + body: bodyA, + node: SceneNode(), + scene: scene, + plane: plane, + ); + final componentB = RigidBodyComponent( + body: bodyB, + node: SceneNode(), + scene: scene, + plane: plane, + ); + + final registry = { + bodyA.collider: componentA, + bodyB.collider: componentB, + }; + PositionComponent? resolve(Collider other) => registry[other]; + + CollisionBridge( + collider: bodyA.collider, + component: componentA, + resolveOther: resolve, + ); + CollisionBridge( + collider: bodyB.collider, + component: componentB, + resolveOther: resolve, + ); + + world.update(); + + expect(componentA.isColliding, isTrue); + expect(componentA.collidingWith(componentB), isTrue); + expect(componentB.collidingWith(componentA), isTrue); + }); + + test('reports a plausible 2D point: the midpoint of the two colliders, ' + 'projected through the plane', () { + final world = CollisionWorld(); + final scene = Scene(); + final plane = BridgePlane.ground(); + + final bodyA = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.0, 0.0, 0.0), + ); + final bodyB = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.2, 0.0, 0.5), + ); + + final componentA = RigidBodyComponent( + body: bodyA, + node: SceneNode(), + scene: scene, + plane: plane, + ); + final componentB = RigidBodyComponent( + body: bodyB, + node: SceneNode(), + scene: scene, + plane: plane, + ); + + final registry = { + bodyA.collider: componentA, + bodyB.collider: componentB, + }; + + List? capturedPoints; + componentA.onCollisionStartCallback = (points, other) { + capturedPoints = points; + }; + + CollisionBridge( + collider: bodyA.collider, + component: componentA, + resolveOther: (other) => registry[other], + ); + CollisionBridge( + collider: bodyB.collider, + component: componentB, + resolveOther: (other) => registry[other], + ); + + world.update(); + + final expectedMidpoint = plane.to2d( + (bodyA.position + bodyB.position) * 0.5, + ); + expect(capturedPoints, {expectedMidpoint}); + }); + + test('calls nothing when resolveOther finds no bridged component for the ' + 'other side', () { + final world = CollisionWorld(); + final scene = Scene(); + final plane = BridgePlane.ground(); + + final bodyA = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.0, 0.0, 0.0), + ); + // Overlaps bodyA, but nothing on the Flame side is registered for it. + RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.2, 0.0, 0.0), + ); + + final componentA = RigidBodyComponent( + body: bodyA, + node: SceneNode(), + scene: scene, + plane: plane, + ); + + CollisionBridge( + collider: bodyA.collider, + component: componentA, + resolveOther: (_) => null, + ); + + world.update(); + + expect(componentA.isColliding, isFalse); + }); + + test('fires onCollisionEnd once the two colliders separate', () { + final world = CollisionWorld(); + final scene = Scene(); + final plane = BridgePlane.ground(); + + final bodyA = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.0, 0.0, 0.0), + ); + final bodyB = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.2, 0.0, 0.0), + ); + + final componentA = RigidBodyComponent( + body: bodyA, + node: SceneNode(), + scene: scene, + plane: plane, + ); + final componentB = RigidBodyComponent( + body: bodyB, + node: SceneNode(), + scene: scene, + plane: plane, + ); + + final registry = { + bodyA.collider: componentA, + bodyB.collider: componentB, + }; + PositionComponent? resolve(Collider other) => registry[other]; + + CollisionBridge( + collider: bodyA.collider, + component: componentA, + resolveOther: resolve, + ); + CollisionBridge( + collider: bodyB.collider, + component: componentB, + resolveOther: resolve, + ); + + world.update(); + expect(componentA.collidingWith(componentB), isTrue); + + bodyB.collider.moveTo(Vector3(20.0, 0.0, 0.0)); + world.update(); + + expect(componentA.collidingWith(componentB), isFalse); + expect(componentB.collidingWith(componentA), isFalse); + }); + + test('detach stops the relay, and leaves a listener it no longer holds ' + 'alone', () { + final (:world, :a, :b, :bridge) = _pair(); + + bridge.detach(); + world.update(); + + expect(a.collidingWith(b), isFalse, reason: 'a detached bridge relayed'); + expect(a.body.collider.listener, isNull); + + // Mutation: clear the listener unconditionally, and a second bridge put + // on the same collider is torn off by the first one's detach. + final second = CollisionBridge( + collider: a.body.collider, + component: a, + resolveOther: (Collider other) => b, + ); + bridge.detach(); + expect(a.body.collider.listener, same(second)); + }); + + testWithFlameGame('a component removed from its game hears nothing', ( + game, + ) async { + final (:world, :a, :b, bridge: _) = _pair(); + await game.ensureAdd(a); + a.removeFromParent(); + await game.ready(); + expect(a.isRemoved, isTrue); + + world.update(); + + expect(a.collidingWith(b), isFalse); + }); +} + +/// Two overlapping bodies with components, the first bridged, the second +/// only in the registry. +({ + CollisionWorld world, + RigidBodyComponent a, + RigidBodyComponent b, + CollisionBridge bridge, +}) +_pair() { + final world = CollisionWorld(); + final scene = Scene(); + final plane = BridgePlane.ground(); + RigidBodyComponent at(double x) => RigidBodyComponent( + body: RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(x, 0.0, 0.0), + ), + node: SceneNode(), + scene: scene, + plane: plane, + ); + final a = at(0.0); + final b = at(0.2); + final bridge = CollisionBridge( + collider: a.body.collider, + component: a, + resolveOther: (Collider other) => other == b.body.collider ? b : null, + ); + return (world: world, a: a, b: b, bridge: bridge); +} diff --git a/packages/flame_flutter3d/test/curvilinear_space_test.dart b/packages/flame_flutter3d/test/curvilinear_space_test.dart new file mode 100644 index 00000000000..ea9dfb8f387 --- /dev/null +++ b/packages/flame_flutter3d/test/curvilinear_space_test.dart @@ -0,0 +1,50 @@ +/// Flame's straight world laid along a road that bends. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; + +/// Forty metres ahead, then a right-angle turn right. +OpenPath _road() => OpenPath([ + Vector3(0.0, 0.0, 0.0), + Vector3(0.0, 0.0, -40.0), + Vector3(30.0, 0.0, -40.0), +]); + +void main() { + testWithGame( + 'a car across and along the road is on the bend, facing along it', + FlameGame.new, + (game) async { + // Mutation: place it on the flat plane however the road runs. + final car = Object3dComponent( + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + space: CurvilinearSpace(_road()), + elevation: 0.5, + position: Vector2(2.0, -55.0), + ); + game.add(car); + await game.ready(); + game.update(0.0); + + final at = car.node.readPosition(); + // Fifteen metres into the turn, two to the right of the middle, which + // after turning right is towards the camera (+z). + expect(at.x, closeTo(15.0, 1e-5)); + expect(at.y, closeTo(0.5, 1e-5)); + expect(at.z, closeTo(-38.0, 1e-5)); + + final facing = car.node.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + expect(facing.x, closeTo(1.0, 1e-5), reason: 'along the road'); + expect(car.scenePosition.distanceTo(at), lessThan(1e-5)); + }, + ); +} diff --git a/packages/flame_flutter3d/test/flame_input_bridge_test.dart b/packages/flame_flutter3d/test/flame_input_bridge_test.dart new file mode 100644 index 00000000000..87590638fec --- /dev/null +++ b/packages/flame_flutter3d/test/flame_input_bridge_test.dart @@ -0,0 +1,124 @@ +/// A [FlameInputBridge] translates Flame's own keyboard and drag callbacks +/// into the same [Bindings]/[InputState] calls `flutter3d_game`'s +/// `DesktopInput` makes, so both write into one shared [InputState]. +library; + +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/src/input/flame_input_bridge.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show KeyEventResult; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +KeyDownEvent _down(LogicalKeyboardKey key) => KeyDownEvent( + logicalKey: key, + physicalKey: PhysicalKeyboardKey.keyW, + timeStamp: Duration.zero, +); + +KeyUpEvent _up(LogicalKeyboardKey key) => KeyUpEvent( + logicalKey: key, + physicalKey: PhysicalKeyboardKey.keyW, + timeStamp: Duration.zero, +); + +/// A [DragUpdateEvent] carrying [delta], built without mounting any widget. +/// +/// [DragUpdateEvent.deviceDelta] is derived purely from the +/// [DragUpdateDetails] passed to the constructor, so a bare [FlameGame] that +/// is never added to a widget tree is enough to build one by hand. +DragUpdateEvent _drag(FlameGame game, Offset delta) => DragUpdateEvent( + 1, + game, + DragUpdateDetails(globalPosition: Offset.zero, delta: delta), +); + +void main() { + late Bindings bindings; + late InputState state; + late FlameInputBridge bridge; + + setUp(() { + bindings = Bindings({ + InputSource.key(LogicalKeyboardKey.keyW.keyId): GameAction.moveForward, + }); + state = InputState(); + bridge = FlameInputBridge(bindings: bindings, inputState: state); + }); + + test('a bound key press latches its action as held', () { + final consumed = !bridge.onKeyEvent( + _down(LogicalKeyboardKey.keyW), + {LogicalKeyboardKey.keyW}, + ); + + expect(consumed, isTrue); + expect(state.held(GameAction.moveForward), isTrue); + }); + + test('the matching release lets the action go', () { + bridge.onKeyEvent(_down(LogicalKeyboardKey.keyW), { + LogicalKeyboardKey.keyW, + }); + + bridge.onKeyEvent(_up(LogicalKeyboardKey.keyW), {}); + + expect(state.held(GameAction.moveForward), isFalse); + }); + + test('an unbound key is left alone, for the game to handle itself', () { + final notConsumed = bridge.onKeyEvent( + _down(LogicalKeyboardKey.keyQ), + {LogicalKeyboardKey.keyQ}, + ); + + expect(notConsumed, isTrue); + expect(state.held(GameAction.moveForward), isFalse); + }); + + test('onGameKeyEvent answers a game: handled for a bound key, ignored for ' + 'the rest', () { + // Mutation: return the component's polarity unflipped, and an unbound + // key reads as handled, which is how a game loses its own shortcuts. + expect( + bridge.onGameKeyEvent( + _down(LogicalKeyboardKey.keyW), + {LogicalKeyboardKey.keyW}, + ), + KeyEventResult.handled, + ); + expect(state.held(GameAction.moveForward), isTrue); + expect( + bridge.onGameKeyEvent( + _down(LogicalKeyboardKey.keyQ), + {LogicalKeyboardKey.keyQ}, + ), + KeyEventResult.ignored, + ); + }); + + test('a drag accumulates into the shared look delta', () { + final game = FlameGame(); + + bridge.onDragUpdate(_drag(game, const Offset(3.0, -1.0))); + bridge.onDragUpdate(_drag(game, const Offset(2.0, 4.0))); + + expect(state.lookDelta.x, 5.0); + expect(state.lookDelta.y, 3.0); + }); + + test('endStep drains the look delta, as InputState documents', () { + final game = FlameGame(); + + bridge.onDragUpdate(_drag(game, const Offset(10.0, 10.0))); + state + ..beginStep() + ..endStep(); + + expect(state.lookDelta.x, 0.0); + expect(state.lookDelta.y, 0.0); + }); +} diff --git a/packages/flame_flutter3d/test/flutter3d_flame_widget_test.dart b/packages/flame_flutter3d/test/flutter3d_flame_widget_test.dart new file mode 100644 index 00000000000..22ebbd6c436 --- /dev/null +++ b/packages/flame_flutter3d/test/flutter3d_flame_widget_test.dart @@ -0,0 +1,265 @@ +/// [Flutter3dFlameWidget] builds and ticks both layers without throwing. +/// +/// **These were skipped as hanging, and do not hang.** A Flame +/// `GameWidget` under `flutter_test` was said to hang in this environment, +/// and the evidence was a test runner that ran for its whole timeout with +/// no output. Run with `flutter test` directly, both finish in seconds: the +/// runner, not Flame, was what stood still. With the skip in place the +/// package's own host widget had no test at all. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart' show SceneSurface; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('an empty game and an empty scene compose and tick', ( + tester, + ) async { + final camera = CameraNode(name: 'eye'); + var ticks = 0; + + await tester.pumpWidget( + MaterialApp( + home: Flutter3dFlameWidget( + game: FlameGame(), + camera: camera, + buildScene: (device) => Scene(), + onTick: (double dt) => ticks++, + width: 32, + height: 24, + ), + ), + ); + await tester.pump(); + + expect(find.byType(GameWidget), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 16)); + + expect(ticks, greaterThan(0)); + }); + + testWidgets('an existing device and renderer are reused, not reopened', ( + tester, + ) async { + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final camera = CameraNode(name: 'eye'); + GraphicsDevice? seen; + + await tester.pumpWidget( + MaterialApp( + home: Flutter3dFlameWidget( + game: FlameGame(), + camera: camera, + existing: (device: device, renderer: renderer), + buildScene: (d) { + seen = d; + return Scene(); + }, + ), + ), + ); + await tester.pump(); + + expect(seen, same(device), reason: 'no second device should open'); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('a rebuild with another camera draws through it', (tester) async { + // A cut to a second camera, or a new sky, handed in from above: both + // went into the view once and a rebuild changed nothing on screen. + // + // Mutation: build the view once, in initState. + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final first = CameraNode(name: 'first'); + final second = CameraNode(name: 'second'); + final game = FlameGame(); + final scene = Scene(); + + Widget host(CameraNode camera) => MaterialApp( + home: Flutter3dFlameWidget( + game: game, + camera: camera, + existing: (device: device, renderer: renderer), + buildScene: (_) => scene, + ), + ); + + await tester.pumpWidget(host(first)); + await tester.pumpWidget(host(second)); + await tester.pump(); + + final surface = tester.widget(find.byType(SceneSurface)); + expect(surface.view.camera, same(second)); + expect(scene.cameras, contains(second)); + }); + + testWidgets("Flame's overlays are shown over both layers", (tester) async { + // A pause menu over the 3D layer needed a second Stack of the host's + // own; the GameWidget already draws overlays, and was never given them. + // + // Mutation: build the GameWidget without the overlay map. + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final camera = CameraNode(); + await tester.pumpWidget( + MaterialApp( + home: Flutter3dFlameWidget( + game: FlameGame(), + camera: camera, + existing: (device: device, renderer: renderer), + buildScene: (_) => Scene(), + overlayBuilderMap: >{ + 'pause': (context, game) => const Text('PAUSED'), + }, + initialActiveOverlays: const ['pause'], + ), + ), + ); + await tester.pump(); + expect(find.text('PAUSED'), findsOneWidget); + }); + + testWidgets('new overlay builders under the same names reach the screen ' + 'without a new GameWidget', (tester) async { + // A map written inline in a parent's build is new every rebuild, and a + // new GameWidget for it had Flame update the game again from layout. + // + // Mutation: pass the config's map to GameWidget directly and keep it; + // the overlay goes on saying "score 1". + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final camera = CameraNode(); + final game = FlameGame(); + final scene = Scene(); + Widget host(int score) => MaterialApp( + home: Flutter3dFlameWidget( + game: game, + camera: camera, + existing: (device: device, renderer: renderer), + buildScene: (_) => scene, + overlayBuilderMap: >{ + 'score': (context, game) => Text('score $score'), + }, + initialActiveOverlays: const ['score'], + ), + ); + + await tester.pumpWidget(host(1)); + await tester.pump(); + final before = tester.widget(find.byType(GameWidget)); + expect(find.text('score 1'), findsOneWidget); + + await tester.pumpWidget(host(2)); + await tester.pump(); + expect(find.text('score 2'), findsOneWidget); + expect( + tester.widget(find.byType(GameWidget)), + same(before), + reason: 'the same names keep the same GameWidget', + ); + }); + + testWidgets('a host that did not start does not tick a game another host ' + 'is showing', (tester) async { + // Its clock went into the game from its first build, and every update + // then called both hosts' onTick. + // + // Mutation: add the clock before the host is ready. + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final game = FlameGame(); + var shown = 0; + var failed = 0; + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + Expanded( + child: Flutter3dFlameWidget( + game: game, + camera: CameraNode(), + existing: (device: device, renderer: renderer), + buildScene: (_) => Scene(), + onTick: (double _) => shown++, + ), + ), + Expanded( + child: Flutter3dFlameWidget( + game: game, + camera: CameraNode(), + existing: (device: device, renderer: renderer), + buildScene: (_) => throw StateError('no level'), + onTick: (double _) => failed++, + ), + ), + ], + ), + ), + ); + for (var i = 0; i < 4; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect(shown, greaterThan(0)); + expect(failed, 0); + }); + + testWidgets('a host that goes lets go of the game it drew for', ( + tester, + ) async { + // Compared by `identical` against a fresh tear-off, which never is, the + // game kept calling back into, and holding, the disposed host. + // + // Mutation: compare `owner.redrawer3d` with `_redraw` again. + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final game = _Owned(); + await tester.pumpWidget( + MaterialApp( + home: Flutter3dFlameWidget( + game: game, + existing: (device: device, renderer: renderer), + buildScene: (_) => Scene(), + ), + ), + ); + await tester.pump(); + expect(game.redrawer3d, isNotNull); + + await tester.pumpWidget(const SizedBox()); + expect(game.redrawer3d, isNull); + }); +} + +final class _Owned extends FlameGame with HasFlutter3d {} diff --git a/packages/flame_flutter3d/test/follows_forge2d_test.dart b/packages/flame_flutter3d/test/follows_forge2d_test.dart new file mode 100644 index 00000000000..a056e6ec4ce --- /dev/null +++ b/packages/flame_flutter3d/test/follows_forge2d_test.dart @@ -0,0 +1,90 @@ +/// A body of Flame's own 2D physics, drawn in 3D: a pinball and a flipper +/// moved by `flame_forge2d`, followed by bridged components. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' show SceneNode; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Table extends Forge2DGame with HasFlutter3d { + _Table() : super(gravity: Vector2(0.0, 10.0)); +} + +final class _Ball extends BodyComponent { + @override + Body createBody() { + final body = world.createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(2.0, 0.0)), + ); + body.createShape(Circle(radius: 0.3)); + return body; + } +} + +final class _Flipper extends BodyComponent { + @override + Body createBody() { + final body = world.createBody( + BodyDef( + type: BodyType.kinematic, + position: Vector2(-2.0, 5.0), + angularVelocity: 3.0, + ), + ); + body.createShape(Polygon.box(1.0, 0.1)); + return body; + } +} + +void main() { + testWithGame<_Table>( + 'a bridged component stands where a forge2d body is, and turns as it ' + 'turns', + _Table.new, + (game) async { + // A body of flame_forge2d is not a PositionComponent, and nothing of + // the bridge could hang under it. + // + // Mutation: ignore follows. + game.open3d(FakeBackend()); + final ball = _Ball(); + final flipper = _Flipper(); + game.world.addAll([ball, flipper]); + await game.ready(); + final plane = BridgePlane.ground(); + final drawnBall = Object3dComponent( + node: SceneNode(), + scene: game.scene, + plane: plane, + direction: SyncDirection.flameToScene, + follows: ball, + ); + final drawnFlipper = Object3dComponent( + node: SceneNode(), + scene: game.scene, + plane: plane, + direction: SyncDirection.flameToScene, + follows: flipper, + ); + game.addAll([drawnBall, drawnFlipper]); + await game.ready(); + + for (var i = 0; i < 30; i++) { + game.update(1 / 60); + } + expect(ball.body.position.y, greaterThan(1.0), reason: 'it fell'); + final at = drawnBall.node.readPosition(); + expect(at.x, closeTo(ball.body.position.x, 1e-5)); + expect(at.z, closeTo(ball.body.position.y, 1e-5)); + expect(flipper.body.angle, greaterThan(1.0)); + expect( + plane.angleFor(drawnFlipper.node.readRotation()), + closeTo(flipper.body.angle, 1e-4), + ); + }, + ); +} diff --git a/packages/flame_flutter3d/test/grid_mover_test.dart b/packages/flame_flutter3d/test/grid_mover_test.dart new file mode 100644 index 00000000000..a57eb5c9579 --- /dev/null +++ b/packages/flame_flutter3d/test/grid_mover_test.dart @@ -0,0 +1,180 @@ +/// A grid as a world: walked a cell at a time, drawn as instances, and met +/// through Flame's own collision. +library; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d, HasCollisionDetection {} + +Future<({_World game, CellGridComponent maze})> _maze( + List mask, { + bool instanced = false, + bool hitboxes = false, +}) async { + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final maze = CellGridComponent( + grid: CellGrid.fromMask(mask), + device: device, + scene: game.scene, + plane: BridgePlane.ground(), + material: engine.Material(), + instanced: instanced, + hitboxes: hitboxes, + ); + game.add(maze); + await game.ready(); + return (game: game, maze: maze); +} + +Future<(PositionComponent, GridMover)> _walker( + _World game, + CellGridComponent maze, + Vector2 at, { + bool wraps = false, + void Function(int, int)? onArrive, +}) async { + final mover = GridMover( + grid: maze, + speed: 2.0, + wraps: wraps, + onArrive: onArrive, + ); + final body = PositionComponent(position: at)..add(mover); + game.add(body); + await game.ready(); + return (body, mover); +} + +void main() { + const maze = ['#####', '#...#', '#.#.#', '#...#', '#####']; + + test('a turn asked for early is taken at the first junction open to it, ' + 'and a wall stops it', () async { + // Mutation: take the wanted turn only if it is open where the mover + // stands when asked. + final (:game, maze: grid) = await _maze(maze); + final arrived = <(int, int)>[]; + final (body, mover) = await _walker( + game, + grid, + Vector2(1.4, 1.6), + onArrive: (c, r) => arrived.add((c, r)), + ); + expect(body.position, Vector2(1.5, 1.5), reason: 'stood in its cell'); + + mover.wanted = GridHeading.right; + game.update(0.25); + expect(body.position.x, closeTo(2.0, 1e-9)); + + // Down is a wall under the next cell: kept until the corner. + mover.wanted = GridHeading.down; + for (var i = 0; i < 4; i++) { + game.update(0.25); + } + expect(body.position.x, closeTo(3.5, 1e-9)); + expect(body.position.y, closeTo(2.0, 1e-9)); + expect(mover.heading, GridHeading.down); + + for (var i = 0; i < 8; i++) { + game.update(0.25); + } + expect(body.position, Vector2(3.5, 3.5), reason: 'the wall stopped it'); + expect(mover.heading, GridHeading.none); + expect(arrived, <(int, int)>[(2, 1), (3, 1), (3, 2), (3, 3)]); + }); + + test('a turn back is taken at once, between cells', () async { + final (:game, maze: grid) = await _maze(maze); + final (body, mover) = await _walker(game, grid, Vector2(1.5, 1.5)); + mover.wanted = GridHeading.right; + game.update(0.25); + mover.wanted = GridHeading.left; + game.update(0.125); + expect(body.position.x, closeTo(1.75, 1e-9)); + expect(mover.heading, GridHeading.left); + }); + + test('through the tunnel, off one edge and in at the other', () async { + // Mutation: stop at the grid's edge whatever wraps says. + final (:game, maze: grid) = await _maze(['#####', '.....']); + final (body, mover) = await _walker( + game, + grid, + Vector2(0.5, 1.5), + wraps: true, + ); + mover.wanted = GridHeading.left; + game.update(0.5); + expect(mover.cell, (4, 1)); + expect(body.position.x, closeTo(4.5, 1e-9)); + }); + + test('drawn as instances, a cell taken is a slot given back', () async { + // Mutation: rebuild a merged mesh in instanced mode. + final (:game, maze: grid) = await _maze(maze, instanced: true); + final batch = grid.node.childrenView.whereType().single; + expect(batch.count, grid.grid.count); + final before = batch.count; + + expect(grid.hitAt(Vector2(2.5, 2.5), radius: 0.3), isTrue); + expect(batch.count, before - 1); + expect(grid.setCell(1, 1), isTrue); + expect(batch.count, before); + expect( + grid.node.childrenView + .whereType() + .whereType(), + hasLength(1), + reason: 'no merged mesh beside the batch', + ); + }); + + test("with hitboxes, Flame's own collision meets a cell, and not one " + 'taken away', () async { + // Mutation: one hitbox round the whole grid. + final (:game, maze: grid) = await _maze(maze, hitboxes: true); + expect( + grid.children.whereType(), + hasLength(grid.grid.count), + ); + final ball = _Ball(Vector2(2.5, 2.5)); + game.add(ball); + await game.ready(); + game.update(0.0); + expect(ball.touched, contains(grid)); + + grid.hitAt(Vector2(2.5, 2.5), radius: 0.3); + await game.ready(); + ball.touched.clear(); + game.update(0.0); + expect(ball.touched, isEmpty, reason: 'the cell under it went'); + }); +} + +final class _Ball extends PositionComponent with CollisionCallbacks { + _Ball(Vector2 at) + : super( + position: at, + size: Vector2.all(0.4), + anchor: Anchor.center, + children: [CircleHitbox()], + ); + + final Set touched = {}; + + @override + void onCollision(List points, PositionComponent other) { + super.onCollision(points, other); + touched.add(other); + } +} diff --git a/packages/flame_flutter3d/test/has_fixed_step_test.dart b/packages/flame_flutter3d/test/has_fixed_step_test.dart new file mode 100644 index 00000000000..e08eb7da3ba --- /dev/null +++ b/packages/flame_flutter3d/test/has_fixed_step_test.dart @@ -0,0 +1,184 @@ +/// A game whose own logic runs in fixed steps, and input that waits for a +/// step to read it. +library; + +import 'package:flame/components.dart' + show CircleComponent, Component, JoystickComponent; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Stepped extends FlameGame with HasFixedStep { + int steps = 0; + + @override + void fixedUpdate(double step) => steps++; +} + +final class _Ticker extends Component with FixedStepUpdate { + int steps = 0; + + @override + void fixedUpdate(double step) => steps++; +} + +void main() { + testWithGame<_Stepped>( + 'the game and its stepped components run once per step, not per frame', + _Stepped.new, + (game) async { + final ticker = _Ticker(); + game.add(ticker); + await game.ready(); + + for (var i = 0; i < 4; i++) { + game.update(1 / 120); + } + expect(game.steps, 2, reason: 'four half-steps are two steps'); + expect(ticker.steps, 2); + + game.update(1 / 30); + expect(game.stepsThisFrame, 2); + expect(ticker.steps, 4); + }, + ); + + testWithGame<_Stepped>( + 'a press in a frame with no step waits for the next step', + _Stepped.new, + (game) async { + // Mutation: close the input step every frame, step or not. + final input = FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + game.add(input.stepEnd()); + await game.ready(); + const fire = GameAction('fire'); + + input.inputState.press(fire); + game.update(1 / 240); + expect(game.stepsThisFrame, 0); + expect(input.inputState.pressed(fire), isTrue, reason: 'still unread'); + + game.update(1 / 60); + expect(game.stepsThisFrame, 1); + expect(input.inputState.pressed(fire), isFalse, reason: 'read, closed'); + }, + ); + + testWithGame<_Stepped>( + 'a press is seen by one step of a frame that has three', + _Stepped.new, + (game) async { + // Closed once a frame, all three steps saw the jump's press, and the + // runner jumped three times. + // + // Mutation: close the input step at the end of the frame. + final input = FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + const jump = GameAction.jump; + final reader = _Reads(input.inputState, jump); + game.addAll([input.stepEnd(), reader]); + await game.ready(); + + input.inputState.press(jump); + game.update(3 / 60); + expect(game.stepsThisFrame, 3); + expect(reader.presses, 1); + expect(input.inputState.held(jump), isTrue); + }, + ); + + testWithGame<_Stepped>( + 'the steps of a frame read the stick as it is that frame', + _Stepped.new, + (game) async { + // The steps run before any component updates, and the stick was read + // in its own component's update: a frame late. + // + // Mutation: read the stick in the feed's update. + final input = FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + final stick = JoystickComponent( + knob: CircleComponent(radius: 10.0), + background: CircleComponent(radius: 40.0), + ); + final steers = _Steers(input.inputState); + game.addAll([ + stick, + input.followJoystick(stick), + steers, + ]); + await game.ready(); + + stick.delta.setValues(stick.knobRadius, 0.0); + game.update(1 / 60); + expect(steers.seen, closeTo(1.0, 1e-9)); + }, + ); + + testWithGame<_Stepped>( + "physics and actors step in the game's steps, and draw by its alpha", + _Stepped.new, + (game) async { + // Three clocks counted three sets of steps: the runner moved in the + // game's, the crates in their own, never in turn. + // + // Mutation: step PhysicsStepComponent from its own FixedStep. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world); + var physicsSteps = 0; + final physics = PhysicsStepComponent( + dynamics: dynamics, + world: world, + afterStep: () => physicsSteps++, + step: FixedStep(stepSeconds: 1 / 30), + ); + final actors = ActorSystemComponent( + system: ActorSystem(world: world, random: GameRandom(1)), + focus: Vector3.zero, + step: FixedStep(stepSeconds: 1 / 30), + ); + game.addAll([physics, actors]); + await game.ready(); + + game.update(3 / 60 + 1 / 120); + expect(physicsSteps, game.steps); + expect(physics.alpha, game.alpha); + expect(actors.alpha, game.alpha); + }, + ); +} + +final class _Steers extends Component with FixedStepUpdate { + _Steers(this.input); + + final InputState input; + double seen = 0.0; + + @override + void fixedUpdate(double step) => seen = input.moveAxis.x; +} + +final class _Reads extends Component with FixedStepUpdate { + _Reads(this.input, this.action); + + final InputState input; + final GameAction action; + int presses = 0; + + @override + void fixedUpdate(double step) { + if (input.pressed(action)) { + presses++; + } + } +} diff --git a/packages/flame_flutter3d/test/has_flutter3d_test.dart b/packages/flame_flutter3d/test/has_flutter3d_test.dart new file mode 100644 index 00000000000..8b9a9996ce3 --- /dev/null +++ b/packages/flame_flutter3d/test/has_flutter3d_test.dart @@ -0,0 +1,256 @@ +/// A Flame game that owns its 3D world: opened on a device, built once, and +/// hosted by `Flutter3dFlameWidget` with nothing but the game. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart' + show DidNotStart, SceneSurface; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d { + int built = 0; + Renderer? handed; + final SceneNode floor = SceneNode(name: 'floor'); + + @override + void onOpen3d() { + built++; + scene.add(floor); + } + + @override + void onRenderer3d(Renderer renderer) { + // What the world built is there to be given the renderer. + expect(built, closed + 1); + handed = renderer; + } + + int closed = 0; + + @override + void onClose3d() { + closed++; + floor.removeFromParent(); + } +} + +final class _Broken extends FlameGame with HasFlutter3d { + @override + void onOpen3d() => throw StateError('no river today'); +} + +Widget _shown(FlameGame game) => + MaterialApp(home: Flutter3dFlameWidget(game: game, width: 32, height: 24)); + +CpuDevice _device() => CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), +); + +void main() { + test('opened before it is loaded, it builds once it has loaded', () async { + final game = _World()..open3d(_device()); + expect(game.built, 0, reason: 'nothing to build a game on yet'); + await initializeGame(() => game); + expect(game.built, 1); + expect(game.scene.cameras, contains(game.camera3d)); + expect(game.floor.parent, isNotNull); + }); + + test('opened after it is loaded, it builds at once, and only once', () async { + final game = await initializeGame(_World.new); + expect(game.has3d, isFalse); + expect(() => game.scene, throwsStateError); + + game.open3d(_device()); + expect(game.built, 1); + expect(() => game.open3d(_device()), throwsStateError); + expect(game.built, 1); + }); + + test('its background lets the 3D layer through', () { + expect(_World().backgroundColor().a, 0.0); + }); + + testWidgets('the widget hosts it with nothing but the game', (tester) async { + final device = _device(); + final renderer = Renderer.create(device: device); + final game = _World(); + + await tester.pumpWidget( + MaterialApp( + home: Flutter3dFlameWidget( + game: game, + existing: (device: device, renderer: renderer), + ), + ), + ); + await tester.pump(); + + expect(game.device, same(device)); + expect(game.handed, same(renderer)); + expect(game.built, 1); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + test( + 'a renderer handed over before it is loaded waits for the world', + () async { + // The widget's order: the device opens, the renderer is made, and only + // then does Flame load the game. + final device = _device(); + final renderer = Renderer.create(device: device); + final game = _World() + ..open3d(device) + ..attachRenderer(renderer); + expect(game.handed, isNull); + await initializeGame(() => game); + expect(game.handed, same(renderer)); + }, + ); + + testWidgets('shown again, it draws the world it kept', (tester) async { + // A tab that comes back: Flame keeps the game's components, and the + // world built on the device has to stay with them. + // + // Mutation: close the device the widget opened when the widget goes. + final game = _World(); + await tester.pumpWidget(_shown(game)); + await tester.pump(); + final device = game.device; + final scene = game.scene; + final renderer = game.handed; + expect(renderer, isNotNull); + + await tester.pumpWidget(const SizedBox()); + expect(game.has3d, isTrue, reason: 'the world goes with the game'); + + await tester.pumpWidget(_shown(game)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(game.built, 1, reason: 'built once, shown twice'); + expect(game.device, same(device)); + expect(game.scene, same(scene)); + expect(game.handed, same(renderer)); + final surface = tester.widget(find.byType(SceneSurface)); + expect( + surface.renderer, + same(renderer), + reason: 'drawn by the renderer its world was handed', + ); + expect(tester.takeException(), isNull); + game.close3d(); + }); + + testWidgets('closed, it builds its world afresh the next time it is ' + 'shown', (tester) async { + final game = _World(); + await tester.pumpWidget(_shown(game)); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + game.close3d(); + expect(game.has3d, isFalse); + expect(game.closed, 1); + expect(game.floor.parent, isNull); + + await tester.pumpWidget(_shown(game)); + await tester.pump(); + expect(game.built, 2); + expect(game.scene.cameras, contains(game.camera3d)); + game.close3d(); + }); + + testWidgets('another game handed in gets a world of its own', (tester) async { + // Mutation: keep the state across a change of game. + final first = _World(); + final second = _World(); + await tester.pumpWidget(_shown(first)); + await tester.pump(); + await tester.pumpWidget(_shown(second)); + await tester.pump(); + + expect(second.built, 1); + final surface = tester.widget(find.byType(SceneSurface)); + expect(surface.scene, same(second.scene)); + expect(surface.scene, isNot(same(first.scene))); + first.close3d(); + second.close3d(); + }); + + testWidgets('a world that throws while it is built says why', (tester) async { + // Mutation: build the GameWidget without an error builder. + final game = _Broken(); + await tester.pumpWidget(_shown(game)); + await tester.pump(); + await tester.pump(); + expect(find.byType(DidNotStart), findsOneWidget); + expect(find.textContaining('no river today'), findsWidgets); + game.close3d(); + }); + + testWidgets('a split screen draws its second view beside the first', ( + tester, + ) async { + // The renderer drew several views and the surface was handed one. + // + // Mutation: hand the surface the game's camera alone. + final game = _World(); + await tester.pumpWidget(_shown(game)); + await tester.pump(); + final second = CameraNode(name: 'player two'); + game.scene.add(second); + game + ..viewport3d = const ViewportRect(0.0, 0.0, 0.5, 1.0) + ..moreViews3d.add( + RenderView( + camera: second, + viewportFraction: const ViewportRect(0.5, 0.0, 0.5, 1.0), + ), + ); + game.update(1 / 60); + await tester.pump(); + await tester.pump(); + + final surface = tester.widget(find.byType(SceneSurface)); + expect(surface.moreViews.single.camera, same(second)); + expect(surface.view.viewportFraction.width, 0.5); + expect(tester.takeException(), isNull); + game.close3d(); + }); + + testWidgets('paused, it is drawn again when asked', (tester) async { + // A pause menu that changes the sky: nothing ticks, so nothing drew it. + // + // Mutation: leave redraw3d unconnected. + final game = _World(); + await tester.pumpWidget(_shown(game)); + await tester.pump(); + game.pauseEngine(); + // The last tick's redraw lands a frame or two after it. + for (var i = 0; i < 3; i++) { + await tester.pump(const Duration(milliseconds: 16)); + } + final before = tester.widget(find.byType(SceneSurface)); + await tester.pump(const Duration(milliseconds: 16)); + expect( + tester.widget(find.byType(SceneSurface)), + same(before), + reason: 'a paused game is not redrawn by itself', + ); + + game.redraw3d(); + await tester.pump(); + await tester.pump(); + expect( + tester.widget(find.byType(SceneSurface)), + isNot(same(before)), + ); + game.close3d(); + }); +} diff --git a/packages/flame_flutter3d/test/input_step_pointer_test.dart b/packages/flame_flutter3d/test/input_step_pointer_test.dart new file mode 100644 index 00000000000..f351d958ae9 --- /dev/null +++ b/packages/flame_flutter3d/test/input_step_pointer_test.dart @@ -0,0 +1,201 @@ +/// The input step closed for the game, a pointer followed as an aim and a +/// tap as an action, and a swipe as a press. +library; + +import 'package:flame/components.dart' + show CircleComponent, Component, JoystickComponent; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const GameAction _fire = GameAction('fire'); +const GameAction _hop = GameAction('hop'); + +FlameInputBridge _bridge() => FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), +); + +void main() { + testWithGame( + 'the step is closed at the end of the frame, after everything read it', + FlameGame.new, + (game) async { + // A game that forgot to close the step saw a key pressed once as + // pressed on every frame after. + // + // Mutation: never call endStep. + final input = _bridge(); + var seen = 0; + game.addAll([ + input.stepEnd(), + _Reader(() { + if (input.inputState.pressed(_fire)) { + seen++; + } + }), + ]); + await game.ready(); + + input.inputState.press(_fire); + game.update(1 / 60); + game.update(1 / 60); + expect(seen, 1, reason: 'pressed once, read as pressed once'); + expect(input.inputState.held(_fire), isTrue); + }, + ); + + testWithGame( + 'the pointer is an aim, and a tap holds an action while it is down', + FlameGame.new, + (game) async { + final input = _bridge(); + final pointer = input.pointer(press: _fire); + game.add(pointer); + await game.ready(); + expect(pointer.aim, isNull); + + pointer.onTapDown( + TapDownEvent( + 1, + game, + TapDownDetails( + globalPosition: const Offset(120.0, 80.0), + localPosition: const Offset(120.0, 80.0), + ), + ), + ); + expect(pointer.aim, Vector2(120.0, 80.0)); + expect(input.inputState.held(_fire), isTrue); + + pointer.onTapUp( + TapUpEvent( + 1, + game, + TapUpDetails( + kind: PointerDeviceKind.touch, + globalPosition: const Offset(120.0, 80.0), + localPosition: const Offset(120.0, 80.0), + ), + ), + ); + expect(input.inputState.held(_fire), isFalse); + }, + ); + + testWithGame( + 'a finger that slides to aim keeps the action held until it lifts', + FlameGame.new, + (game) async { + // Flutter gives up on a tap once the finger moves, and firing while + // dragging to aim stopped the moment the aim moved. + // + // Mutation: let go on a tap's cancel. + final input = _bridge(); + final pointer = input.pointer(press: _fire); + game.add(pointer); + await game.ready(); + + pointer + ..onTapDown( + TapDownEvent( + 1, + game, + TapDownDetails(globalPosition: const Offset(100.0, 100.0)), + ), + ) + ..onTapCancel(TapCancelEvent(1)) + ..onDragStart( + DragStartEvent( + 1, + game, + DragStartDetails(globalPosition: const Offset(100.0, 100.0)), + ), + ); + await Future.delayed(Duration.zero); + expect(input.inputState.held(_fire), isTrue, reason: 'still down'); + + pointer.onDragEnd(DragEndEvent(1, DragEndDetails())); + expect(input.inputState.held(_fire), isFalse); + }, + ); + + testWithGame( + "a touch stick at rest leaves a pad's stick alone", + FlameGame.new, + (game) async { + // Written every frame, a resting touch stick wrote zero over the pad. + // + // Mutation: write the deflection every frame. + final input = _bridge(); + final stick = JoystickComponent( + knob: CircleComponent(radius: 10.0), + background: CircleComponent(radius: 40.0), + position: Vector2(100.0, 100.0), + ); + game.addAll([stick, input.followJoystick(stick)]); + await game.ready(); + + input.inputState.setStickAxis(0.5, 0.0); + game.update(1 / 60); + expect(input.inputState.moveAxis.x, closeTo(0.5, 1e-9)); + }, + ); + + testWithGame( + 'a swipe presses the action of its direction, once', + FlameGame.new, + (game) async { + final input = _bridge(); + final swipes = input.swipes(up: _hop); + game.add(swipes); + await game.ready(); + + void drag(Offset by) { + swipes.onDragStart( + DragStartEvent( + 1, + game, + DragStartDetails(globalPosition: const Offset(200.0, 300.0)), + ), + ); + swipes.onDragUpdate( + DragUpdateEvent( + 1, + game, + DragUpdateDetails( + globalPosition: const Offset(200.0, 300.0) + by, + delta: by, + ), + ), + ); + swipes.onDragEnd(DragEndEvent(1, DragEndDetails())); + } + + drag(const Offset(5.0, -10.0)); + expect(input.inputState.pressed(_hop), isFalse, reason: 'too short'); + + drag(const Offset(8.0, -90.0)); + expect(input.inputState.pressed(_hop), isTrue); + expect( + input.inputState.held(_hop), + isFalse, + reason: 'a press, not a hold', + ); + }, + ); +} + +final class _Reader extends Component { + _Reader(this.read); + + final void Function() read; + + @override + void update(double dt) => read(); +} diff --git a/packages/flame_flutter3d/test/instanced_object3d_component_test.dart b/packages/flame_flutter3d/test/instanced_object3d_component_test.dart new file mode 100644 index 00000000000..85dadef6bdc --- /dev/null +++ b/packages/flame_flutter3d/test/instanced_object3d_component_test.dart @@ -0,0 +1,166 @@ +/// Many small Flame components of one shape drawn as one batch: a slot each +/// while mounted, the Flame transform written into it, the slot given back +/// when the component goes. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter_test/flutter_test.dart'; + +InstancedMeshNode _batch() => InstancedMeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + Material(), + capacity: 4, +); + +Vector3 _placeOf(InstancedMeshNode batch, int index) { + final m = Matrix4.zero(); + batch.readTransform(index, m); + return m.getTranslation(); +} + +void main() { + testWithGame( + 'each mounted component draws through a slot at its place', + FlameGame.new, + (game) async { + final batch = _batch(); + final shots = [ + for (var i = 0; i < 3; i++) + InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + elevation: 1.5, + position: Vector2(i.toDouble(), -10.0), + ), + ]; + game.addAll(shots); + await game.ready(); + expect(batch.count, 3); + + shots[2].position.y = -20.0; + game.update(1 / 60); + final third = _placeOf(batch, shots[2].slot!.index); + expect(third.x, closeTo(2.0, 1e-6)); + expect(third.y, closeTo(1.5, 1e-6)); + expect(third.z, closeTo(-20.0, 1e-6)); + }, + ); + + testWithGame( + 'a removed component gives its slot back at once, and the others keep ' + 'theirs', + FlameGame.new, + (game) async { + final batch = _batch(); + final first = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + position: Vector2(1.0, 0.0), + ); + final last = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + position: Vector2(5.0, 0.0), + ); + game.addAll([first, last]); + await game.ready(); + + first.removeFromParent(); + expect(batch.count, 1, reason: 'not drawn in the frame it went'); + await game.ready(); + expect(batch.count, 1, reason: 'given back once, not twice'); + expect(_placeOf(batch, last.slot!.index).x, closeTo(5.0, 1e-6)); + }, + ); + + testWithGame('a hidden component draws nothing', FlameGame.new, ( + game, + ) async { + final batch = _batch(); + final blinking = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + position: Vector2(3.0, 0.0), + ); + game.add(blinking); + await game.ready(); + + blinking.isVisible = false; + game.update(1 / 60); + final m = Matrix4.identity(); + batch.readTransform(blinking.slot!.index, m); + expect(m.getMaxScaleOnAxis(), 0.0); + + blinking.isVisible = true; + game.update(1 / 60); + expect(_placeOf(batch, blinking.slot!.index).x, closeTo(3.0, 1e-6)); + }); + + testWithGame( + 'a still instance leaves its batch unchanged, and a moved one does not', + FlameGame.new, + (game) async { + // Mutation: write the slot whether or not the component moved. + final batch = _batch(); + final shot = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + position: Vector2(1.0, 0.0), + ); + game.add(shot); + await game.ready(); + game.update(1 / 60); + + final version = batch.dataVersion; + game.update(1 / 60); + game.update(1 / 60); + expect(batch.dataVersion, version); + + shot.position.y = -5.0; + game.update(1 / 60); + expect(batch.dataVersion, greaterThan(version)); + expect(_placeOf(batch, shot.slot!.index).z, closeTo(-5.0, 1e-6)); + }, + ); + + testWithGame( + 'an instance has a tint and an opacity of its own', + FlameGame.new, + (game) async { + // A hit flash on one of many: the others keep their colour. + // + // Mutation: never write the slot's colour after it is taken. + final batch = _batch(); + final a = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + ); + final b = InstancedObject3dComponent( + batch: batch, + plane: BridgePlane.ground(), + position: Vector2(2.0, 0.0), + ); + game.addAll([a, b]); + await game.ready(); + + a.tint.setValues(1.0, 0.2, 0.2, 1.0); + b.add(OpacityEffect.to(0.5, EffectController(duration: 0.1))); + game.update(0.2); + + Vector4 colourOf(InstancedObject3dComponent c) { + final at = c.slot!.index * InstancedMeshNode.floatsPerInstance + 12; + final d = batch.instanceData; + return Vector4(d[at], d[at + 1], d[at + 2], d[at + 3]); + } + + expect(colourOf(a), Vector4(1.0, 0.2, 0.2, 1.0)); + expect(colourOf(b).w, closeTo(0.5, 1e-6)); + expect(colourOf(b).x, 1.0, reason: 'only faded, not tinted'); + }, + ); +} diff --git a/packages/flame_flutter3d/test/kinematic_body_component_test.dart b/packages/flame_flutter3d/test/kinematic_body_component_test.dart new file mode 100644 index 00000000000..3255d35c3eb --- /dev/null +++ b/packages/flame_flutter3d/test/kinematic_body_component_test.dart @@ -0,0 +1,105 @@ +/// A lift Flame moves with its own effects, and a runner carried on it. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Side extends FlameGame with HasFixedStep {} + +void main() { + testWithGame<_Side>( + 'a lift moved by a Flame effect carries whoever stands on it, once for ' + 'each move', + _Side.new, + (game) async { + // Written into the collider by hand, the lift rose and its passenger + // stayed; carried on every step, the passenger rose twice as fast. + // + // Mutation: move the collider without recording the motion, or leave + // the motion recorded for the steps after. + final world = CollisionWorld(); + final deck = world.add( + Collider( + shape: CollisionBox(Vector3(2.0, 0.25, 2.0)), + position: Vector3(0.0, -0.25, 0.0), + kind: ColliderKind.kinematic, + ), + ); + final body = CharacterController( + world: world, + position: Vector3(0.0, 0.9, 0.0), + ); + final plane = BridgePlane.backdrop(); + final lift = KinematicBodyComponent( + collider: deck, + node: SceneNode(), + scene: Scene(), + plane: plane, + position: plane.to2d(deck.position), + ); + final rider = CharacterBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: plane, + drive: (dt) { + body.step(dt, wishDirection: Vector3.zero()); + world.update(); + }, + ); + game.addAll([lift, rider]); + await game.ready(); + for (var i = 0; i < 30; i++) { + game.update(1 / 60); + } + final standing = body.position.x; + + // Sideways, where nothing but the recorded motion moves a passenger: + // a metre and a half over a second, at two steps a frame. + lift.add( + MoveEffect.by(Vector2(1.5, 0.0), EffectController(duration: 1.0)), + ); + for (var i = 0; i < 40; i++) { + game.update(1 / 30); + } + expect(deck.position.x, closeTo(1.5, 1e-3)); + expect(body.position.x - standing, closeTo(1.5, 0.05)); + }, + ); + + testWithGame<_Side>( + 'a lift removed from the game leaves the world with removeFrom', + _Side.new, + (game) async { + // Mutation: drop the removal from `onRemove`; nothing is drawn where + // the lift was, and a passenger still stands on it. + final world = CollisionWorld(); + final deck = world.add( + Collider( + shape: CollisionBox(Vector3(2.0, 0.25, 2.0)), + kind: ColliderKind.kinematic, + ), + ); + final lift = KinematicBodyComponent( + collider: deck, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.backdrop(), + removeFrom: world, + ); + game.add(lift); + await game.ready(); + + lift.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(deck.world, isNull); + }, + ); +} diff --git a/packages/flame_flutter3d/test/model_animation_test.dart b/packages/flame_flutter3d/test/model_animation_test.dart new file mode 100644 index 00000000000..81381d8aabb --- /dev/null +++ b/packages/flame_flutter3d/test/model_animation_test.dart @@ -0,0 +1,101 @@ +/// A model's animations on Flame's clock, and a flipbook of meshes. +library; + +import 'dart:typed_data'; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A clip that slides node 0 from x = 0 to x = [to] over a second. +AnimationClip _slide(String name, double to) => AnimationClip( + name: name, + tracks: [ + AnimationTrack( + nodeIndex: 0, + path: AnimationPath.translation, + interpolation: AnimationInterpolation.linear, + times: Float32List.fromList([0.0, 1.0]), + values: Float32List.fromList([0.0, 0.0, 0.0, to, 0.0, 0.0]), + componentCount: 3, + ), + ], +); + +void main() { + testWithGame( + "a clip plays on Flame's clock, and a change of clip is by name", + FlameGame.new, + (game) async { + // Mutation: tick the player from anywhere but the component's update. + final body = SceneNode(); + final player = AnimationPlayer( + clips: [_slide('walk', 2.0), _slide('run', 6.0)], + targets: [body], + ); + final animation = ModelAnimationComponent(player, start: 'walk'); + game.add(animation); + await game.ready(); + + game.update(0.5); + expect(body.readPosition().x, closeTo(1.0, 1e-3)); + + // Only by what the game was stepped: nothing else ticks it. + game.update(0.25); + expect(body.readPosition().x, closeTo(1.5, 1e-3)); + + expect(animation.play('run'), isTrue); + expect(animation.current, 'run'); + expect(animation.play('fly'), isFalse); + expect(animation.has('walk'), isTrue); + }, + ); + + testWithGame( + 'a flipbook shows its frames in turn', + FlameGame.new, + (game) async { + final a = CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()); + final b = CpuMesh(CuboidShape(size: Vector3.all(2.0)).build()); + final node = MeshNode(a, Material()); + final book = MeshFlipbookComponent( + node: node, + frames: [a, b], + ); + game.add(book); + await game.ready(); + expect(node.mesh, same(a)); + + game.update(0.6); + expect(node.mesh, same(b)); + game.update(0.5); + expect(node.mesh, same(a)); + }, + ); + + testWithGame( + 'a clip asked for again from its start plays again', + FlameGame.new, + (game) async { + // Asking for the clip playing did nothing, so a jump played once. + // + // Mutation: ignore restart. + final body = SceneNode(); + final player = AnimationPlayer( + clips: [_slide('jump', 2.0)], + targets: [body], + ); + final animation = ModelAnimationComponent(player, start: 'jump'); + game.add(animation); + await game.ready(); + game.update(0.5); + expect(body.readPosition().x, closeTo(1.0, 1e-3)); + + expect(animation.play('jump', restart: true), isTrue); + game.update(0.25); + expect(body.readPosition().x, closeTo(0.5, 1e-3)); + }, + ); +} diff --git a/packages/flame_flutter3d/test/node3d_component_test.dart b/packages/flame_flutter3d/test/node3d_component_test.dart new file mode 100644 index 00000000000..30242738868 --- /dev/null +++ b/packages/flame_flutter3d/test/node3d_component_test.dart @@ -0,0 +1,137 @@ +/// A Flame component in full 3D: moved, turned and scaled by Flame's +/// effect controllers, nested as the scene nests, and tapped. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Space extends FlameGame with HasFlutter3d {} + +final class _Fighter extends Node3dComponent with Tap3dCallbacks { + _Fighter(GraphicsDevice device, Scene scene, {super.position}) + : super( + node: MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ), + Material(), + ), + scene: scene, + ); + + int taps = 0; + + @override + void onTap3d(Vector2 screen) => taps++; +} + +Future<({_Space game, CpuDevice device})> _open() async { + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final game = await initializeGame(_Space.new); + game.open3d(device); + return (game: game, device: device); +} + +void main() { + test("moved, turned and scaled by Flame's effect controllers", () async { + // Mutation: apply the whole move at each step rather than its share. + final (:game, :device) = await _open(); + final fighter = _Fighter(device, game.scene); + fighter.addAll([ + Move3dEffect.by( + Vector3(0.0, 4.0, -10.0), + EffectController(duration: 1.0), + ), + Rotate3dEffect.by( + Vector3(0.0, 1.0, 0.0), + 1.5707963267948966, + EffectController(duration: 1.0), + ), + Scale3dEffect.to(Vector3(2.0, 1.0, 3.0), EffectController(duration: 1.0)), + ]); + game.add(fighter); + await game.ready(); + + game.update(0.5); + expect(fighter.node.readPosition().z, closeTo(-5.0, 1e-5)); + for (var i = 0; i < 4; i++) { + game.update(0.25); + } + final at = fighter.node.readPosition(); + expect(at.y, closeTo(4.0, 1e-5)); + expect(at.z, closeTo(-10.0, 1e-5)); + final forward = fighter.node.readRotation().asRotationMatrix().transform( + Vector3(0.0, 0.0, -1.0), + ); + expect(forward.x, closeTo(-1.0, 1e-5), reason: 'a quarter turn left'); + final scale = fighter.node.readScale(); + expect(scale.x, closeTo(2.0, 1e-5)); + expect(scale.z, closeTo(3.0, 1e-5)); + }); + + test('an alternating controller brings it back where it began', () async { + final (:game, :device) = await _open(); + final fighter = _Fighter(device, game.scene) + ..add( + Move3dEffect.by( + Vector3(6.0, 0.0, 0.0), + EffectController(duration: 1.0, alternate: true), + ), + ); + game.add(fighter); + await game.ready(); + for (var i = 0; i < 8; i++) { + game.update(0.25); + } + expect(fighter.position3.x, closeTo(0.0, 1e-5)); + }); + + test('a turret turns with its tank, and a cockpit camera flies with its ' + 'ship', () async { + // Mutation: add every node to the scene's root. + final (:game, :device) = await _open(); + final tank = _Fighter(device, game.scene, position: Vector3(5.0, 0.0, 0.0)); + final turret = _Fighter( + device, + game.scene, + position: Vector3(0.0, 1.0, 0.0), + ); + tank.add(turret); + game.add(tank); + await game.ready(); + tank.node.add(game.camera3d..setPosition(0.0, 0.5, 0.0)); + + tank.position3.x = 9.0; + game.update(0.0); + expect(turret.node.readWorldPosition().x, closeTo(9.0, 1e-5)); + expect(turret.node.readWorldPosition().y, closeTo(1.0, 1e-5)); + expect(game.camera3d.readWorldPosition().x, closeTo(9.0, 1e-5)); + }); + + test('a tap on it is heard, as on anything bridged', () async { + // Mutation: ask taps of bridged components on a plane alone. + final (:game, :device) = await _open(); + game.camera3d + ..setPosition(0.0, 2.0, 8.0) + ..lookAt(Vector3.zero()); + final fighter = _Fighter(device, game.scene); + final taps = Taps3dComponent(); + game.addAll([fighter, taps]); + await game.ready(); + game.update(0.0); + + final screen = game.projector.toScreen(Vector3.zero())!; + expect(taps.nearestAt(screen), same(fighter)); + }); +} diff --git a/packages/flame_flutter3d/test/object3d_component_test.dart b/packages/flame_flutter3d/test/object3d_component_test.dart new file mode 100644 index 00000000000..32d4c1f9a6a --- /dev/null +++ b/packages/flame_flutter3d/test/object3d_component_test.dart @@ -0,0 +1,110 @@ +/// An [Object3dComponent] keeps a Flame position and a flutter3d [SceneNode] +/// at the same place, on whichever side [SyncDirection] names. +library; + +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('mounting adds the node to the scene, once', () { + final scene = Scene(); + final node = SceneNode(); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + ); + + component.onMount(); + + expect(node.parent, scene.root); + }); + + test('removing detaches the node from the scene', () { + final scene = Scene(); + final node = SceneNode(); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + component.onRemove(); + + expect(node.parent, isNull); + }); + + test('sceneToFlame copies the node onto the Flame position each frame', () { + final scene = Scene(); + final node = SceneNode()..setPosition(1.0, 0.0, 2.0); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + node.setPosition(3.0, 0.0, 4.0); + component.update(1 / 60); + + expect(component.position, Vector2(3.0, 4.0)); + }); + + test('flameToScene copies the Flame position onto the node each frame', () { + final scene = Scene(); + final node = SceneNode(); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(height: 1.5), + direction: SyncDirection.flameToScene, + )..onMount(); + + component.position = Vector2(5.0, 6.0); + // `updateTree`, which is what Flame calls: flowing Flame to the scene, + // the sync runs after the subtree, so an effect has moved it first. + component.updateTree(1 / 60); + + final read = node.readPosition(); + expect(read.x, 5.0); + expect(read.y, 1.5); + expect(read.z, 6.0); + }); + + test('size and anchor go to Flame, and the anchor is the point the scene ' + 'gets', () { + final scene = Scene(); + final node = SceneNode(); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + position: Vector2(4.0, -6.0), + size: Vector2(2.0, 1.0), + anchor: Anchor.center, + )..onMount(); + + component.updateTree(1 / 60); + + expect(component.size, Vector2(2.0, 1.0)); + // The centre, not the top-left corner, is what lands in the scene. + expect(component.absoluteCenter, Vector2(4.0, -6.0)); + expect(node.readPosition(), Vector3(4.0, 0.0, -6.0)); + }); + + test('a sceneToFlame component leaves the node alone on update', () { + final scene = Scene(); + final node = SceneNode()..setPosition(9.0, 0.0, 9.0); + final component = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + )..onMount(); + + component.update(1 / 60); + + expect(node.readPosition(), Vector3(9.0, 0.0, 9.0)); + }); +} diff --git a/packages/flame_flutter3d/test/object3d_transform_test.dart b/packages/flame_flutter3d/test/object3d_transform_test.dart new file mode 100644 index 00000000000..97152d9775e --- /dev/null +++ b/packages/flame_flutter3d/test/object3d_transform_test.dart @@ -0,0 +1,443 @@ +/// The rest of Flame's transform crossing into the scene: after the effects +/// that move it, from wherever in Flame's tree the component sits, off the +/// plane, scaled, shown or hidden, and with a node under it the bridge leaves +/// alone. Run in a mounted game, because effects and parents are what is +/// being tested and neither does anything outside one. +library; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; + +Object3dComponent _bridged( + Scene scene, { + Vector2? position, + BridgePlane? plane, + double elevation = 0.0, +}) => Object3dComponent( + node: SceneNode(name: 'bridged'), + scene: scene, + plane: plane ?? BridgePlane.ground(), + direction: SyncDirection.flameToScene, + elevation: elevation, + position: position, +); + +void main() { + testWithGame( + "an effect's move reaches the scene in the frame it happens", + FlameGame.new, + (game) async { + final scene = Scene(); + final component = _bridged(scene) + ..add( + MoveEffect.by(Vector2(10.0, 0.0), EffectController(duration: 1.0)), + ); + game.add(component); + await game.ready(); + + game.update(0.5); + // Synced in `update`, before the effect ran, this read 0. + expect(component.position.x, closeTo(5.0, 1e-6)); + expect(component.node.readPosition().x, closeTo(5.0, 1e-6)); + }, + ); + + testWithGame( + 'a component nested in another lands where Flame draws it', + FlameGame.new, + (game) async { + final scene = Scene(); + final log = PositionComponent(position: Vector2(10.0, 4.0)); + final frog = _bridged(scene, position: Vector2(1.0, 0.0)); + game.add(log); + log.add(frog); + await game.ready(); + + game.update(0.0); + expect(frog.node.readPosition(), Vector3(11.0, 0.0, 4.0)); + + log.position.x = 20.0; + game.update(0.0); + expect(frog.node.readPosition().x, closeTo(21.0, 1e-6)); + }, + ); + + testWithGame( + "flowing the other way, a nested component reads its parent's space", + FlameGame.new, + (game) async { + final scene = Scene(); + final holder = PositionComponent(position: Vector2(10.0, 0.0)); + final node = SceneNode()..setPosition(12.0, 0.0, 3.0); + final body = Object3dComponent( + node: node, + scene: scene, + plane: BridgePlane.ground(), + ); + game.add(holder); + holder.add(body); + await game.ready(); + + game.update(0.0); + expect(body.position, Vector2(2.0, 3.0)); + expect(body.absolutePosition, Vector2(12.0, 3.0)); + }, + ); + + testWithGame( + 'a flipped, turned component under a flipped, turned parent is drawn ' + 'where Flame draws it', + FlameGame.new, + (game) async { + // Flame's absolute angle is reflected for a flipped component, and + // written beside the signed scale it mirrored twice. + // + // Mutation: write absoluteAngle with absoluteScale. + final scene = Scene(); + final plane = BridgePlane.ground(); + final parent = PositionComponent( + position: Vector2(3.0, 2.0), + angle: 0.4, + scale: Vector2(-1.0, 1.0), + ); + final ship = _bridged(scene, position: Vector2(1.0, 1.0), plane: plane) + ..angle = 0.3 + ..scale = Vector2(1.0, -1.0); + game.add(parent); + parent.add(ship); + await game.ready(); + game.update(0.0); + + for (final local in [Vector2(1.0, 0.0), Vector2(0.0, 1.0)]) { + final flame = ship.absolutePositionOf(local); + final drawn = plane.to2d( + ship.node.worldMatrix.transformed3(Vector3(local.x, 0.0, local.y)), + ); + expect(drawn.x, closeTo(flame.x, 1e-5), reason: 'at $local'); + expect(drawn.y, closeTo(flame.y, 1e-5), reason: 'at $local'); + } + }, + ); + + testWithGame( + 'a plain component between a frog and its log does not hide the log', + FlameGame.new, + (game) async { + // Mutation: ask only the parent whether it is positioned. + final scene = Scene(); + final log = PositionComponent(position: Vector2(10.0, 4.0)); + final layer = Component(); + final frog = _bridged(scene, position: Vector2(1.0, 0.0)); + game.add(log); + log.add(layer); + layer.add(frog); + await game.ready(); + + game.update(0.0); + expect(frog.node.readPosition(), Vector3(11.0, 0.0, 4.0)); + }, + ); + + testWithGame( + 'read back under a flipped parent, the turn comes back as it went out', + FlameGame.new, + (game) async { + // Mutation: subtract the parent's absoluteAngle and nothing else. + final scene = Scene(); + final plane = BridgePlane.ground(); + final parent = PositionComponent( + position: Vector2(3.0, 2.0), + angle: 0.4, + scale: Vector2(-1.0, 1.0), + ); + final writer = _bridged(scene, position: Vector2(1.0, 1.0), plane: plane) + ..angle = 0.3; + final reader = Object3dComponent( + node: writer.node, + scene: scene, + plane: plane, + ); + game.add(parent); + parent.addAll([writer, reader]); + await game.ready(); + game + ..update(0.0) + ..update(0.0); + + expect(reader.position.x, closeTo(1.0, 1e-5)); + expect(reader.position.y, closeTo(1.0, 1e-5)); + expect(reader.angle, closeTo(0.3, 1e-5)); + }, + ); + + testWithGame( + 'elevation lifts the node off the plane, and scenePosition says where', + FlameGame.new, + (game) async { + final scene = Scene(); + final jet = _bridged( + scene, + position: Vector2(2.0, -5.0), + plane: BridgePlane.ground(height: 0.5), + elevation: 1.2, + ); + game.add(jet); + await game.ready(); + + game.update(0.0); + expect(jet.node.readPosition().y, closeTo(1.7, 1e-6)); + expect(jet.scenePosition, jet.node.readPosition()); + + jet.elevation = 0.0; + game.update(0.0); + expect(jet.node.readPosition().y, closeTo(0.5, 1e-6)); + }, + ); + + testWithGame( + "Flame's scale scales the node, the normal by the mean of the two", + FlameGame.new, + (game) async { + final scene = Scene(); + final rock = _bridged(scene)..scale = Vector2(2.0, 3.0); + final sign = _bridged(scene, plane: BridgePlane.backdrop()) + ..scale = Vector2(2.0, 4.0); + game.addAll([rock, sign]); + await game.ready(); + + game.update(0.0); + expect(rock.node.readScale(), Vector3(2.0, 2.5, 3.0)); + expect(sign.node.readScale(), Vector3(2.0, 4.0, 3.0)); + }, + ); + + testWithGame( + "Flame's visibility is written when it changes, and only then", + FlameGame.new, + (game) async { + final scene = Scene(); + final ship = _bridged(scene); + game.add(ship); + await game.ready(); + + ship.isVisible = false; + game.update(0.0); + expect(ship.node.visible, isFalse); + + ship.isVisible = true; + game.update(0.0); + expect(ship.node.visible, isTrue); + + // Blinking the node by hand, as a hit flash does, is left alone. + ship.node.visible = false; + game.update(0.0); + expect(ship.node.visible, isFalse); + }, + ); + + testWithGame( + 'a component let go stops being drawn at once', + FlameGame.new, + (game) async { + final scene = Scene(); + final shot = _bridged(scene); + game.add(shot); + await game.ready(); + game.update(0.0); + expect(shot.node.visible, isTrue); + + shot.removeFromParent(); + // Before Flame has processed the removal: already hidden. + expect(shot.node.visible, isFalse); + await game.ready(); + expect(shot.node.parent, isNull); + }, + ); + + testWithGame( + 'the visual node is made on demand, under the node, and left alone', + FlameGame.new, + (game) async { + final scene = Scene(); + final craft = _bridged(scene)..angle = 0.7; + game.add(craft); + await game.ready(); + + final visual = craft.visual; + expect(craft.visual, same(visual)); + expect(visual.parent, same(craft.node)); + + final bank = Quaternion.axisAngle(Vector3(0.0, 0.0, 1.0), 0.4); + visual.setRotation(bank); + game.update(0.1); + expect(visual.readRotation().z, closeTo(bank.z, 1e-6)); + expect(craft.node.readRotation().z, isNot(closeTo(bank.z, 1e-6))); + }, + ); + + testWithGame( + 'a hidden parent hides its child in the scene, as Flame draws it', + FlameGame.new, + (game) async { + // A frog on a log: the log blinks, and Flame stops drawing the frog + // with it. The frog's node is not under the log's, so the bridge has + // to ask the frog's ancestors, not only the frog. + // + // Mutation: write the child's own `isVisible` alone. + final scene = Scene(); + final log = _bridged(scene); + final frog = _bridged(scene); + log.add(frog); + game.add(log); + await game.ready(); + + log.isVisible = false; + game.update(1 / 60); + expect(frog.node.visible, isFalse); + + log.isVisible = true; + game.update(1 / 60); + expect(frog.node.visible, isTrue); + }, + ); + + testWithGame( + 'a child under a scaled parent is scaled by both, as its place is', + FlameGame.new, + (game) async { + // Its position already carries the parent's scale; its size has to + // as well, or the model and the hitbox disagree about how big it is. + // + // Mutation: scale the node by the component's own `scale`. + final scene = Scene(); + final parent = _bridged(scene)..scale = Vector2.all(2.0); + final child = _bridged(scene, position: Vector2(1.0, 0.0)) + ..scale = Vector2.all(1.5); + parent.add(child); + game.add(parent); + await game.ready(); + game.update(1 / 60); + + expect(child.node.readPosition().x, closeTo(2.0, 1e-6)); + expect(child.node.readScale().x, closeTo(3.0, 1e-6)); + }, + ); + + testWithGame( + "Flame's opacity and a tint reach every mesh under the node", + FlameGame.new, + (game) async { + // A wreck fading out with an OpacityEffect, over a material other + // craft share; and a model dressed onto it later fades with it. + // + // Mutation: write the tint only when it changes, not while it holds. + final scene = Scene(); + final wreck = _bridged(scene); + final hull = MeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + engine.Material(), + ); + wreck.visual.add(hull); + wreck.add(OpacityEffect.to(0.0, EffectController(duration: 1.0))); + game.add(wreck); + await game.ready(); + + game.update(0.5); + expect(hull.tint.w, closeTo(0.5, 1e-6)); + + final model = MeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + engine.Material(), + ); + wreck.visual.add(model); + game.update(0.25); + expect(model.tint.w, closeTo(0.25, 1e-6), reason: 'dressed late'); + + wreck + ..opacity = 1.0 + ..tint.setValues(1.0, 0.2, 0.2, 1.0); + game.update(0.0); + expect(hull.tint, Vector4(1.0, 0.2, 0.2, 1.0)); + + wreck.tint.setValues(1.0, 1.0, 1.0, 1.0); + wreck.children.whereType().toList().forEach( + (e) => e.removeFromParent(), + ); + await game.ready(); + wreck.opacity = 1.0; + game.update(0.0); + expect(hull.tint, Vector4.all(1.0), reason: 'back to plain'); + }, + ); + + testWithGame( + 'a bridged prop that does not move does not mark the scene changed', + FlameGame.new, + (game) async { + // The engine keeps its shadow cascades and its tree of bounds for as + // long as nothing changed, and a still tanker rewriting its place every + // frame had every shadow redrawn every frame. + // + // Mutation: write the transform whether or not it moved. + final scene = Scene(); + final tanker = _bridged(scene, position: Vector2(3.0, -8.0)); + game.add(tanker); + await game.ready(); + game.update(1 / 60); + + final epoch = SceneNode.changeEpoch; + for (var i = 0; i < 10; i++) { + game.update(1 / 60); + } + expect(SceneNode.changeEpoch, epoch, reason: 'nothing moved'); + + tanker.position.x = 4.0; + game.update(1 / 60); + expect(SceneNode.changeEpoch, greaterThan(epoch)); + expect(tanker.node.readPosition().x, closeTo(4.0, 1e-6)); + }, + ); + + testWithGame( + 'a tint effect colours what the component draws, as a colour effect ' + 'would a sprite', + FlameGame.new, + (game) async { + // Flame's ColorEffect wants a paint, and a bridged component has none. + // + // Mutation: leave the tint where it was. + final scene = Scene(); + final hull = MeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + engine.Material(), + ); + final ship = + Object3dComponent( + node: hull, + scene: scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + )..add( + TintEffect( + Vector4(1.0, 0.0, 0.0, 1.0), + EffectController(duration: 1.0), + ), + ); + game.add(ship); + await game.ready(); + + game.update(0.5); + expect(ship.tint.y, closeTo(0.5, 1e-6)); + expect(hull.tint.y, closeTo(0.5, 1e-6), reason: 'on the mesh drawn'); + game.update(0.5); + expect(hull.tint.x, closeTo(1.0, 1e-6)); + expect(hull.tint.y, closeTo(0.0, 1e-6)); + }, + ); +} diff --git a/packages/flame_flutter3d/test/owned_meshes_test.dart b/packages/flame_flutter3d/test/owned_meshes_test.dart new file mode 100644 index 00000000000..1723567e8ad --- /dev/null +++ b/packages/flame_flutter3d/test/owned_meshes_test.dart @@ -0,0 +1,84 @@ +/// Meshes a bridged component made for itself, let go when it goes. +library; + +import 'package:flame/components.dart' show Component, PositionComponent; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d {} + +void main() { + test( + 'a removed component gives its own meshes back, and no others', + () async { + // A bridge's span, built for it, left on the device after the bridge was + // gone: each stretch of river leaked one. + // + // Mutation: let nothing go on removal. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final span = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ); + final shared = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ); + final bridge = Object3dComponent( + node: MeshNode(span, Material()), + scene: game.scene, + plane: BridgePlane.ground(), + owns: [span], + ); + game.add(bridge); + await game.ready(); + expect(device.releasedGeometry, isEmpty); + + bridge.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect( + device.releasedGeometry, + containsAll([span.vertices, span.indices]), + ); + expect(device.releasedGeometry, isNot(contains(shared.vertices))); + }, + ); + + test('a component moved to another parent keeps its meshes', () async { + // Flame moves a component by removing it and mounting it again, and + // the removal let its meshes go while it went on drawing them. + // + // Mutation: let them go in onRemove, at once. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final hull = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ); + final raft = Object3dComponent( + node: MeshNode(hull, Material()), + scene: game.scene, + plane: BridgePlane.ground(), + owns: [hull], + ); + final log = PositionComponent(); + game.addAll([raft, log]); + await game.ready(); + + raft.parent = log; + await game.ready(); + await Future.delayed(Duration.zero); + expect(raft.isMounted, isTrue); + expect(raft.parent, same(log)); + expect(device.releasedGeometry, isEmpty); + expect(raft.node.parent, isNotNull, reason: 'still in the scene'); + }); +} diff --git a/packages/flame_flutter3d/test/particles3d_component_test.dart b/packages/flame_flutter3d/test/particles3d_component_test.dart new file mode 100644 index 00000000000..904d97a0b0a --- /dev/null +++ b/packages/flame_flutter3d/test/particles3d_component_test.dart @@ -0,0 +1,113 @@ +/// A particle system on Flame's clock: bursts placed from Flame points, +/// advanced with the game, drawn through the renderer once there is one and +/// taken out of it with the component. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_particles/flutter3d_particles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final ParticleEffect _spark = ParticleEffect( + count: 5, + emitter: const SphereEmitter(speed: Range.exact(0.0)), + lifetime: const Range.exact(0.5), + size: const Range.exact(0.2), + color: Vector4.all(1.0), +); + +void main() { + testWithGame( + "a burst starts where the Flame point is, and lives on the game's clock", + FlameGame.new, + (game) async { + final particles = Particles3dComponent( + system: ParticleSystem(capacity: 16, seed: 1), + plane: BridgePlane.ground(), + ); + game.add(particles); + await game.ready(); + + expect(particles.burstAt(_spark, Vector2(3.0, -4.0), elevation: 2.0), 5); + // Still, all five: the middle of their bounds is where they started. + final at = Vector3.zero(); + particles.system.boundsInto(at); + expect(at.x, closeTo(3.0, 1e-6)); + expect(at.y, closeTo(2.0, 1e-6)); + expect(at.z, closeTo(-4.0, 1e-6)); + + // Frame by frame: the system caps its catch-up after a stall, so one + // update of a third of a second is not a third of a second of life. + for (var i = 0; i < 18; i++) { + game.update(1 / 60); + } + expect(particles.system.aliveCount, 5); + for (var i = 0; i < 18; i++) { + game.update(1 / 60); + } + expect(particles.system.aliveCount, 0, reason: 'half a second of life'); + }, + ); + + testWithGame( + 'it draws through the renderer it is given, and leaves it with the game', + FlameGame.new, + (game) async { + final cpu = cpuTestDevice(width: 8, height: 8); + final renderer = Renderer.create( + device: cpu.device, + fallbackAlbedo: cpu.albedo, + fallbackNormal: cpu.normal, + ); + final shard = DeviceMesh.upload( + cpu.device, + CuboidShape(size: Vector3.all(0.2)).build(), + ); + final particles = Particles3dComponent( + system: ParticleSystem(capacity: 16, seed: 1), + plane: BridgePlane.ground(), + ); + game.add(particles); + await game.ready(); + + particles.drawWith(renderer, shard); + expect( + renderer.contributors.all.whereType(), + hasLength(1), + ); + // Handed the renderer again: moved, not doubled, and with the blend + // it was given this time. + particles.drawWith( + renderer, + shard, + blend: MeshParticleContributor.darkening, + ); + expect(renderer.contributors.all, hasLength(1)); + expect( + (renderer.contributors.all.single as MeshParticleContributor).blend, + MeshParticleContributor.darkening, + ); + + particles.removeFromParent(); + await game.ready(); + expect( + renderer.contributors.all.whereType(), + isEmpty, + ); + + // Added back, it is drawn again as it was. + // + // Mutation: forget the drawing when it is removed. + game.add(particles); + await game.ready(); + expect(renderer.contributors.all, hasLength(1)); + expect( + (renderer.contributors.all.single as MeshParticleContributor).blend, + MeshParticleContributor.darkening, + ); + }, + ); +} diff --git a/packages/flame_flutter3d/test/physics_step_component_test.dart b/packages/flame_flutter3d/test/physics_step_component_test.dart new file mode 100644 index 00000000000..689f4a9e128 --- /dev/null +++ b/packages/flame_flutter3d/test/physics_step_component_test.dart @@ -0,0 +1,191 @@ +/// [PhysicsStepComponent] steps, then runs its seam, then dispatches. +library; + +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' show Scene, SceneNode; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show FixedStep; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +final class _Log with CollisionListener { + _Log(this.log); + + final List log; + + @override + void onCollisionStart(Collider self, Collider other) => log.add('contact'); +} + +void main() { + test('one update steps the bodies, then runs afterStep, then dispatches ' + 'the contacts afterStep made', () { + // A body flying along X and a trigger that rides five metres above it, + // put there by `afterStep`. A marker waits where the body will be after + // one step. The contact can only be reported if the step ran first, the + // sensor was moved second and the world dispatched last. Mutation: + // dispatch before the step, or before `afterStep`, and the log has no + // contact. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + ), + ); + body.velocity.setValues(60.0, 0.0, 0.0); + final log = []; + final sensor = world.add( + Collider( + shape: CollisionBox(Vector3.all(0.3)), + position: Vector3(0.0, 5.0, 0.0), + kind: ColliderKind.trigger, + listener: _Log(log), + ), + ); + world.add( + Collider( + shape: CollisionBox(Vector3.all(0.1)), + position: Vector3(1.0, 5.0, 0.0), + ), + ); + + PhysicsStepComponent( + dynamics: dynamics, + world: world, + afterStep: () { + log.add('after'); + sensor.position.setFrom(body.position + Vector3(0.0, 5.0, 0.0)); + }, + ).update(1 / 60); + + expect(body.position.x, greaterThan(0.7), reason: 'the body never moved'); + expect(log, ['after', 'contact']); + }); + + test('with no afterStep it still steps and dispatches', () { + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3(0.0, -9.8, 0.0)); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.0, 5.0, 0.0), + ), + ); + + PhysicsStepComponent(dynamics: dynamics, world: world).update(1 / 60); + + expect(body.position.y, lessThan(5.0)); + }); + + ({Dynamics dynamics, RigidBody body, CollisionWorld world}) falling() { + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3(0.0, -9.8, 0.0)); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.0, 50.0, 0.0), + ), + ); + return (dynamics: dynamics, body: body, world: world); + } + + test('the same second of play lands in the same place at any frame rate', () { + // Integrated as the frames come, a body falls a different distance at + // 30 and at 144 frames a second. In steps of one size it cannot. + // + // Mutation: step the solver by the frame's own dt. + double after(double frame) { + final it = falling(); + final stepper = PhysicsStepComponent( + dynamics: it.dynamics, + world: it.world, + ); + for (var t = 0; t < (1.0 / frame).round(); t++) { + stepper.update(frame); + } + return it.body.position.y; + } + + final slow = after(1 / 30); + expect(after(1 / 60), closeTo(slow, 1e-9)); + expect(after(1 / 120), closeTo(slow, 1e-9)); + }); + + test('a stalled frame runs a few steps, not the whole stall', () { + // A laptop lid shut for a second must not ask for sixty steps at once: + // catching up takes longer than the stall and never finishes. + final it = falling(); + var steps = 0; + PhysicsStepComponent( + dynamics: it.dynamics, + world: it.world, + afterStep: () => steps++, + step: FixedStep(maxStepsPerFrame: 4), + ).update(1.0); + expect(steps, 4); + }); + + test('a body handed the stepper is drawn between its last two steps', () { + // Half a step into the frame, the node is half way between where the + // body was and where the last step put it. + final it = falling(); + final stepper = PhysicsStepComponent( + dynamics: it.dynamics, + world: it.world, + ); + final crate = RigidBodyComponent( + body: it.body, + stepper: stepper, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.backdrop(flipY: false), + )..onMount(); + + stepper.update(1 / 60); + final before = it.body.position.y; + stepper.update(1.5 / 60); + final after = it.body.position.y; + crate.update(0.0); + + expect(stepper.alpha, closeTo(0.5, 1e-9)); + expect(crate.node.readPosition().y, closeTo((before + after) / 2.0, 1e-5)); + }); + + test('a body at rest does not mark the scene changed', () { + // The same fault a still bridged prop had: the body's place was written + // onto the node every frame, and a written node redraws every shadow. + // + // Mutation: write the body's place whether or not it moved. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(1.0, 2.0, 3.0), + ), + ); + final stepper = PhysicsStepComponent(dynamics: dynamics, world: world); + final crate = RigidBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + )..onMount(); + stepper.update(1 / 60); + crate.update(1 / 60); + + final epoch = SceneNode.changeEpoch; + for (var i = 0; i < 5; i++) { + stepper.update(1 / 60); + crate.update(1 / 60); + } + expect(SceneNode.changeEpoch, epoch); + expect(crate.node.readPosition(), Vector3(1.0, 2.0, 3.0)); + }); +} diff --git a/packages/flame_flutter3d/test/plane_test.dart b/packages/flame_flutter3d/test/plane_test.dart new file mode 100644 index 00000000000..6f33d3edc6e --- /dev/null +++ b/packages/flame_flutter3d/test/plane_test.dart @@ -0,0 +1,109 @@ +/// A point and an angle round-trip through a [BridgePlane] unchanged. +library; + +import 'dart:math' as math; + +import 'package:flame_flutter3d/src/transform/plane.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart' hide Plane; + +void main() { + group('BridgePlane.ground', () { + final plane = BridgePlane.ground(height: 2.0); + + test('a flat point lands at the fixed height, x and y on x and z', () { + final point = plane.to3d(Vector2(3.0, -4.0)); + + expect(point.x, 3.0); + expect(point.y, 2.0); + expect(point.z, -4.0); + }); + + test('to2d undoes to3d, dropping the height', () { + final flat = Vector2(5.0, 6.0); + + expect(plane.to2d(plane.to3d(flat)), Vector2(5.0, 6.0)); + }); + + test('a right angle round-trips through rotationFor/angleFor', () { + const angle = math.pi / 2; + + expect(plane.angleFor(plane.rotationFor(angle)), closeTo(angle, 1e-6)); + }); + + test('rotationFor turns about the world Y axis', () { + expect(plane.normal, Vector3(0.0, 1.0, 0.0)); + }); + }); + + group('BridgePlane.backdrop', () { + final plane = BridgePlane.backdrop(depth: -1.0); + + test('a flat point lands at the fixed depth, y flipped onto y', () { + final point = plane.to3d(Vector2(3.0, 4.0)); + + expect(point.x, 3.0); + expect(point.y, -4.0, reason: 'screen-down y becomes world-up y'); + expect(point.z, -1.0); + }); + + test('to2d undoes to3d, dropping the depth', () { + final flat = Vector2(1.0, 2.0); + + expect(plane.to2d(plane.to3d(flat)), Vector2(1.0, 2.0)); + }); + + test('an angle round-trips the same as on a ground plane', () { + const angle = -1.2; + + expect(plane.angleFor(plane.rotationFor(angle)), closeTo(angle, 1e-6)); + }); + }); + + test('BridgePlane.backdrop(flipY: false) keeps y aligned literally', () { + final plane = BridgePlane.backdrop(flipY: false); + + expect(plane.to3d(Vector2(0.0, 7.0)).y, 7.0); + }); + + test('a node turned by rotationFor is drawn along the direction to3d puts ' + "Flame's own angle, on every plane", () { + // Through the matrix a node is drawn with, not `Quaternion.rotated`, + // which turns the other way. The round trips above agreed with + // themselves while a ground plane drew every Flame turn mirrored: + // +0.5, clockwise on screen, came out anticlockwise. Mutation: take the + // sign from `rotated` again and the ground cases fail. + final planes = { + 'ground': BridgePlane.ground(), + 'ground, flipped': const BridgePlane( + axis: PlaneAxis.y, + constant: 0.0, + flipY: true, + ), + 'backdrop': BridgePlane.backdrop(), + 'backdrop, unflipped': BridgePlane.backdrop(flipY: false), + }; + for (final MapEntry(:key, :value) in planes.entries) { + for (final angle in [0.5, -1.2, 2.8]) { + final drawn = Matrix4.compose( + Vector3.zero(), + value.rotationFor(angle), + Vector3.all(1.0), + ).transform3(Vector3(1.0, 0.0, 0.0)); + final want = + value.to3d(Vector2(math.cos(angle), math.sin(angle))) - + value.to3d(Vector2.zero()); + expect( + (drawn - want).length, + lessThan(1e-5), + reason: '$key at $angle drew $drawn, wanted $want', + ); + expect( + value.angleFor(value.rotationFor(angle)), + closeTo(angle, 1e-6), + reason: key, + ); + } + } + }); +} diff --git a/packages/flame_flutter3d/test/players_test.dart b/packages/flame_flutter3d/test/players_test.dart new file mode 100644 index 00000000000..d8704fb8c81 --- /dev/null +++ b/packages/flame_flutter3d/test/players_test.dart @@ -0,0 +1,93 @@ +/// Several players at one machine: each key reaches the player it is bound +/// for, and a pad is read on Flame's clock. +library; + +import 'package:flame/components.dart' show Component; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show KeyEventResult; +import 'package:flutter3d_game/flutter3d_game.dart' + show Bindings, InputSource, PadInput; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pad_input/pad_input.dart'; + +const GameAction _up = GameAction('up'); + +FlameInputBridge _player(LogicalKeyboardKey key) => FlameInputBridge( + bindings: Bindings({ + InputSource.key(key.keyId): _up, + }), + inputState: InputState(), +); + +KeyDownEvent _down(LogicalKeyboardKey key) => KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: key, + timeStamp: Duration.zero, +); + +/// A controller with the south face button held, and nothing else. +final class _HeldA extends GamepadPlatform { + @override + bool get isSupported => true; + + @override + Stream get connectionChanges => + const Stream.empty(); + + @override + void read(PadSnapshot out) { + out + ..connected = true + ..setDown(PadButton.faceSouth, down: true); + } +} + +void main() { + test('each key reaches the player it is bound for, and no other', () { + // Forwarded to the first bridge, player two's arrows moved player one. + // + // Mutation: stop at the first player. + final one = _player(LogicalKeyboardKey.keyW); + final two = _player(LogicalKeyboardKey.arrowUp); + final players = PlayerInputs([one, two]); + + expect( + players.onGameKeyEvent(_down(LogicalKeyboardKey.arrowUp), const {}), + KeyEventResult.handled, + ); + expect(two.inputState.held(_up), isTrue); + expect(one.inputState.held(_up), isFalse); + + expect( + players.onGameKeyEvent(_down(LogicalKeyboardKey.keyQ), const {}), + KeyEventResult.ignored, + reason: "a key nobody has is the game's", + ); + expect(players.stepEnds(), hasLength(2)); + }); + + testWithGame( + "a pad beside the keys is read on Flame's clock", + FlameGame.new, + (game) async { + // Nothing ticked a PadInput in a Flame game, and it never moved. + // + // Mutation: leave the pad unticked. + final pad = PadInput( + state: InputState(), + pad: Gamepad(platform: _HeldA()), + ); + final bridge = _player(LogicalKeyboardKey.keyW); + game.addAll([bridge.followPad(pad)]); + await game.ready(); + + expect(pad.heldButtons, isEmpty); + game.update(1 / 60); + expect(pad.heldButtons, contains(PadButton.faceSouth)); + }, + ); +} diff --git a/packages/flame_flutter3d/test/projected_viewfinder_test.dart b/packages/flame_flutter3d/test/projected_viewfinder_test.dart new file mode 100644 index 00000000000..7eca703d426 --- /dev/null +++ b/packages/flame_flutter3d/test/projected_viewfinder_test.dart @@ -0,0 +1,178 @@ +/// Flame's own hit test and conversions, through a perspective 3D camera. +library; + +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter_test/flutter_test.dart'; + +final class _Crate extends PositionComponent with TapCallbacks { + _Crate(Vector2 at) + : super(position: at, size: Vector2.all(1.0), anchor: Anchor.center); +} + +void main() { + final eye = CameraNode() + ..setPosition(0.0, 6.0, 6.0) + ..lookAt(Vector3(0.0, 0.0, -6.0)); + final plane = BridgePlane.ground(); + + FlameGame projected() { + late final FlameGame game; + final world = World(); + return game = FlameGame( + world: world, + camera: CameraComponent( + world: world, + viewfinder: ProjectedViewfinder( + projector: BridgeProjector(camera: eye, viewSize: () => game.size), + plane: plane, + ), + ), + ); + } + + testWithGame( + 'a point on the screen is the point of the plane drawn there', + projected, + (game) async { + // Mutation: map the screen through the viewfinder's affine transform. + final crate = _Crate(Vector2(2.0, -8.0)); + game.world.add(crate); + await game.ready(); + + final projector = BridgeProjector(camera: eye, viewSize: () => game.size); + final screen = projector.toScreen(plane.to3d(crate.position))!; + final back = game.camera.globalToLocal(screen); + expect(back.x, closeTo(2.0, 1e-3)); + expect(back.y, closeTo(-8.0, 1e-3)); + + expect(game.componentsAtPoint(screen), contains(crate)); + expect( + game.camera.localToGlobal(crate.position).distanceTo(screen), + lessThan(1e-3), + ); + }, + ); + + testWithGame( + 'what the camera can see is what the 3D camera shows', + projected, + (game) async { + // Worked out from the viewfinder's own offset and zoom, the rectangle + // was one nobody was looking at under a perspective lens. + // + // Mutation: keep Flame's affine visible rectangle. + final ahead = _Crate(Vector2(2.0, -8.0)); + final behind = _Crate(Vector2(0.0, 20.0)); + game.world.addAll([ahead, behind]); + await game.ready(); + game.update(0.0); + + final seen = game.camera.visibleWorldRect; + expect(seen.contains(const Offset(2.0, -8.0)), isTrue); + expect(seen.bottom, lessThan(6.0), reason: 'nothing behind the eye'); + expect(game.camera.canSee(ahead), isTrue); + expect(game.camera.canSee(behind), isFalse); + }, + ); + + test('a projector for half the canvas draws into that half, and reads ' + 'taps from it', () { + // The other half of a split screen: the lens is the half's shape and + // the screen is still the canvas. + // + // Mutation: project over the whole canvas whatever the viewport. + final half = BridgeProjector( + camera: eye, + viewSize: () => Vector2(800.0, 300.0), + viewport: () => const ViewportRect(0.5, 0.0, 0.5, 1.0), + ); + final alone = BridgeProjector( + camera: eye, + viewSize: () => Vector2(400.0, 300.0), + ); + final point = plane.to3d(Vector2(1.0, -8.0)); + final there = half.toScreen(point)!; + final solo = alone.toScreen(point)!; + expect(there.x, closeTo(solo.x + 400.0, 1e-3)); + expect(there.y, closeTo(solo.y, 1e-3)); + final back = half.onPlane(there, plane)!; + expect(back.x, closeTo(1.0, 1e-3)); + expect(back.y, closeTo(-8.0, 1e-3)); + }); + + testWithGame( + 'through a fixed-resolution viewport, a tap still lands on the crate', + () { + late final FlameGame game; + final world = World(); + return game = FlameGame( + world: world, + camera: CameraComponent.withFixedResolution( + width: 400.0, + height: 300.0, + world: world, + viewfinder: ProjectedViewfinder( + projector: BridgeProjector(camera: eye, viewSize: () => game.size), + plane: plane, + ), + ), + ); + }, + (game) async { + // Flame hands the viewfinder points in the viewport's frame, and the + // projector works in the canvas: every tap landed elsewhere. + // + // Mutation: project the viewport's point as it comes. + final crate = _Crate(Vector2(2.0, -8.0)); + game.world.add(crate); + await game.ready(); + + final projector = BridgeProjector(camera: eye, viewSize: () => game.size); + final screen = projector.toScreen(plane.to3d(crate.position))!; + expect(game.componentsAtPoint(screen), contains(crate)); + }, + ); + + testWithGame( + 'the sky is the far horizon, and hits nothing near', + () { + late final FlameGame game; + final world = World(); + final level = CameraNode() + ..setPosition(0.0, 2.0, 0.0) + ..lookAt(Vector3(0.0, 2.0, -10.0)); + return game = FlameGame( + world: world, + camera: CameraComponent( + world: world, + viewfinder: ProjectedViewfinder( + projector: BridgeProjector( + camera: level, + viewSize: () => game.size, + ), + plane: plane, + ), + ), + ); + }, + (game) async { + final crate = _Crate(Vector2.zero()); + game.world.add(crate); + await game.ready(); + // A NaN reached World's own tap handlers, and a drag that strayed + // above the horizon put its component at NaN for good. + // + // Mutation: hand back NaN for the sky. + final sky = Vector2(game.size.x / 2, 2.0); + final there = game.camera.globalToLocal(sky); + expect(there.x.isFinite && there.y.isFinite, isTrue); + expect(there.y, lessThan(-50.0), reason: 'out ahead, at the horizon'); + expect(game.componentsAtPoint(sky), isNot(contains(crate))); + }, + ); +} diff --git a/packages/flame_flutter3d/test/refinements_test.dart b/packages/flame_flutter3d/test/refinements_test.dart new file mode 100644 index 00000000000..f1b39ff8421 --- /dev/null +++ b/packages/flame_flutter3d/test/refinements_test.dart @@ -0,0 +1,187 @@ +/// Three things a crowd game found: the stepping list is not rebuilt every +/// frame and still sees who came and went, what is read before the steps is +/// read against this frame's time, and an upright card turns about its own +/// plane's normal. +library; + +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart' show GameAction, InputState; +import 'package:flutter_test/flutter_test.dart'; + +final class _Stepped extends FlameGame with HasFixedStep {} + +final class _Ticker extends Component with FixedStepUpdate { + int steps = 0; + + @override + void fixedUpdate(double step) => steps++; +} + +final class _Card extends FlameGame with HasFlutter3d {} + +/// A button on the screen: reads whether [action] was pressed this frame. +final class _Reader extends Component { + _Reader(this.state, this.action); + + final InputState state; + final GameAction action; + bool saw = false; + + @override + void update(double dt) { + super.update(dt); + if (state.pressed(action)) { + saw = true; + } + } +} + +Future _pixel() { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder).drawRect( + const ui.Rect.fromLTWH(0, 0, 1, 1), + ui.Paint()..color = const ui.Color(0xFFFFFFFF), + ); + return recorder.endRecording().toImage(1, 1); +} + +void main() { + testWithGame<_Stepped>( + 'a stepper that joins after the first frame steps, and one that leaves ' + 'stops', + _Stepped.new, + (game) async { + // Mutation: keep the list from the first frame for good. + final first = _Ticker(); + game.add(first); + await game.ready(); + game.update(1 / 60); + expect(first.steps, 1); + + final second = _Ticker(); + game.add(second); + await game.ready(); + game.update(1 / 60); + expect(second.steps, 1, reason: 'joined the steps'); + + first.removeFromParent(); + await game.ready(); + game.update(1 / 60); + expect(first.steps, 2, reason: 'left them'); + expect(second.steps, 2); + }, + ); + + testWithGame<_Stepped>( + 'what is read before the steps is read against this frame', + _Stepped.new, + (game) async { + // Mutation: set the frame's time after the reads, as the pad feed had + // it. + final seen = []; + game.beforeSteps(() => seen.add(game.frameSeconds)); + await game.ready(); + + game + ..update(1 / 60) + ..update(1 / 20); + + expect(seen, [1 / 60, 1 / 20]); + }, + ); + + testWithGame( + 'an input step closed from the world is closed after the viewport has ' + 'read it', + FlameGame.new, + (game) async { + // The viewport is the camera's, and the camera is updated after the + // world: closed inside the world, a press was gone before a button on + // the screen could see it. + // + // Mutation: close the step in the component's own update. + const fire = GameAction('fire'); + final input = FlameInputBridge( + bindings: Bindings({}), + inputState: InputState(), + ); + final reader = _Reader(input.inputState, fire); + game.world.add(input.stepEnd()); + game.camera.viewport.add(reader); + await game.ready(); + + input.inputState.press(fire); + game.update(1 / 60); + + expect(reader.saw, isTrue); + expect(input.inputState.pressed(fire), isFalse, reason: 'then closed'); + }, + ); + + group('an upright card', () { + Future standing( + WidgetTester tester, + _Card game, + BridgePlane plane, + ) async { + final cpu = cpuTestDevice(width: 8, height: 8); + game.open3d(cpu.device); + game.camera3d + ..setPosition(3.0, 0.0, 0.0) + ..lookAt(Vector3.zero()); + late SpriteBillboardComponent card; + await tester.runAsync(() async { + await initializeGame(() => game); + final image = await _pixel(); + card = SpriteBillboardComponent( + animation: SpriteAnimation.spriteList([ + Sprite(image), + ], stepTime: 1.0), + device: cpu.device, + scene: game.scene, + plane: plane, + ); + game.add(card); + await game.ready(); + }); + game.update(0.0); + return card; + } + + testWidgets('on the ground turns about up to face the camera', ( + tester, + ) async { + final card = await standing(tester, _Card(), BridgePlane.ground()); + + final turn = card.visual.readRotation(); + final expected = Quaternion.axisAngle( + Vector3(0.0, 1.0, 0.0), + math.pi / 2, + ); + expect(turn.x, closeTo(expected.x, 1e-6)); + expect(turn.y, closeTo(expected.y, 1e-6)); + expect(turn.z, closeTo(expected.z, 1e-6)); + expect(turn.w, closeTo(expected.w, 1e-6)); + }); + + testWidgets( + 'on a backdrop keeps facing out of it, whatever is to its side', + (tester) async { + // Mutation: turn about world Y whatever the plane, as it did — the + // card swung round an axis lying in its own plane. + final card = await standing(tester, _Card(), BridgePlane.backdrop()); + + final turn = card.visual.readRotation(); + expect(turn.x.abs() + turn.y.abs() + turn.z.abs(), lessThan(1e-6)); + }, + ); + }); +} diff --git a/packages/flame_flutter3d/test/rigid_body_component_test.dart b/packages/flame_flutter3d/test/rigid_body_component_test.dart new file mode 100644 index 00000000000..bbc4a468a9f --- /dev/null +++ b/packages/flame_flutter3d/test/rigid_body_component_test.dart @@ -0,0 +1,197 @@ +/// A [RigidBodyComponent] keeps a real [RigidBody]'s position mirrored onto +/// both a flutter3d [SceneNode] and its own Flame `position`, every frame. +library; + +import 'package:flame/components.dart' show Component, PositionComponent; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('defaults to sceneToFlame — the body is authoritative', () { + final world = CollisionWorld(); + final body = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3.zero(), + ); + final component = RigidBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + ); + + expect(component.direction, SyncDirection.sceneToFlame); + }); + + test( + 'mounting adds the node to the scene, the same as any Object3dComponent', + () { + final world = CollisionWorld(); + final body = RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3.zero(), + ); + final scene = Scene(); + final node = SceneNode(); + final component = RigidBodyComponent( + body: body, + node: node, + scene: scene, + plane: BridgePlane.ground(), + ); + + component.onMount(); + + expect(node.parent, scene.root); + }, + ); + + test('update copies the body position, after applyImpulse and a Dynamics ' + 'step, onto both the node and the Flame position', () { + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3(0.0, 5.0, 0.0), + ), + ); + final scene = Scene(); + final node = SceneNode(); + final plane = BridgePlane.ground(); + final component = RigidBodyComponent( + body: body, + node: node, + scene: scene, + plane: plane, + )..onMount(); + + body.applyImpulse(Vector3(6.0, 0.0, 0.0)); + dynamics.step(1 / 60); + // The body actually moved — otherwise this test would pass even if + // `update` copied nothing. + expect(body.position.x, isNot(0.0)); + + component.update(1 / 60); + + expect(node.readPosition(), body.position); + expect(component.position, plane.to2d(body.position)); + }); + + test('a second update tracks a body that keeps moving', () { + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3(0.5, 0.5, 0.5)), + position: Vector3.zero(), + ), + ); + final scene = Scene(); + final node = SceneNode(); + final plane = BridgePlane.ground(); + final component = RigidBodyComponent( + body: body, + node: node, + scene: scene, + plane: plane, + )..onMount(); + + body.applyImpulse(Vector3(4.0, 0.0, 2.0)); + dynamics.step(1 / 60); + component.update(1 / 60); + final firstX = node.readPosition().x; + + dynamics.step(1 / 60); + component.update(1 / 60); + + expect(node.readPosition(), body.position); + expect(node.readPosition().x, isNot(firstX)); + expect(component.position, plane.to2d(body.position)); + }); + + testWithGame( + 'teleported, a body is drawn where it went, still and awake', + FlameGame.new, + (game) async { + // Written into the collider, a respawn slid across the level. + // + // Mutation: move the collider and nothing else. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + ), + ); + final stepper = PhysicsStepComponent(dynamics: dynamics, world: world); + final crate = RigidBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + stepper: stepper, + ); + game.addAll([stepper, crate]); + await game.ready(); + body + ..applyImpulse(Vector3(3.0, 0.0, 0.0)) + ..sleep(); + game.update(1 / 60 + 1 / 120); + + crate.teleport(Vector3(40.0, 0.0, 0.0)); + game.update(1 / 240); + expect(crate.node.readPosition().x, closeTo(40.0, 1e-6)); + expect(body.velocity.length, 0.0); + expect(body.isAsleep, isFalse); + }, + ); + + testWithGame( + 'handed its dynamics, a removed crate takes its body out of the world', + FlameGame.new, + (game) async { + // Mutation: leave the body behind. + final world = CollisionWorld(); + final dynamics = Dynamics(world: world, gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3.zero(), + ), + ); + final crate = RigidBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + removeFrom: dynamics, + ); + final shelf = PositionComponent(); + game.addAll([crate, shelf]); + await game.ready(); + + crate.parent = shelf; + await game.ready(); + await Future.delayed(Duration.zero); + expect(dynamics.bodies, contains(body), reason: 'moved, not gone'); + + crate.removeFromParent(); + await game.ready(); + await Future.delayed(Duration.zero); + expect(dynamics.bodies, isNot(contains(body))); + expect(dynamics.bodyOf(body.collider), isNull); + }, + ); +} diff --git a/packages/flame_flutter3d/test/sprite_billboard_test.dart b/packages/flame_flutter3d/test/sprite_billboard_test.dart new file mode 100644 index 00000000000..788303ed839 --- /dev/null +++ b/packages/flame_flutter3d/test/sprite_billboard_test.dart @@ -0,0 +1,264 @@ +/// A Flame sprite animation drawn in the scene on a card that faces the +/// camera, rendered for real on the CPU device. +library; + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/painting.dart' show TextStyle; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Road extends FlameGame with HasFlutter3d {} + +const int _size = 24; + +/// Four pixels by two: the left half red, the right half blue. +Future _twoFrames() { + final recorder = ui.PictureRecorder(); + ui.Canvas(recorder) + ..drawRect( + const ui.Rect.fromLTWH(0, 0, 2, 2), + ui.Paint()..color = const ui.Color(0xFFFF0000), + ) + ..drawRect( + const ui.Rect.fromLTWH(2, 0, 2, 2), + ui.Paint()..color = const ui.Color(0xFF0000FF), + ); + return recorder.endRecording().toImage(4, 2); +} + +({int r, int g, int b}) _middle(ByteData? pixels) { + final rgba = pixels!.buffer.asUint8List( + pixels.offsetInBytes, + pixels.lengthInBytes, + ); + const at = ((_size ~/ 2) * _size + _size ~/ 2) * 4; + return (r: rgba[at], g: rgba[at + 1], b: rgba[at + 2]); +} + +void main() { + testWidgets('each frame of a Flame animation is drawn in turn on a card ' + 'that faces the camera', (tester) async { + // Mutation: draw the whole image on the card, or leave it facing +Z. + final cpu = cpuTestDevice(width: _size, height: _size); + final renderer = Renderer.create( + device: cpu.device, + fallbackAlbedo: cpu.albedo, + fallbackNormal: cpu.normal, + ); + final game = _Road()..open3d(cpu.device); + // Looking at the card from its side: it has to turn to be seen at all. + game.camera3d + ..setPosition(3.0, 0.5, 0.0) + ..lookAt(Vector3(0.0, 0.5, 0.0)); + await tester.runAsync(() async { + await initializeGame(() => game); + final image = await _twoFrames(); + final animation = SpriteAnimation.fromFrameData( + image, + SpriteAnimationData.sequenced( + amount: 2, + stepTime: 0.5, + textureSize: Vector2(2.0, 2.0), + ), + ); + game.add( + SpriteBillboardComponent( + animation: animation, + device: cpu.device, + scene: game.scene, + plane: BridgePlane.ground(), + ), + ); + await game.ready(); + }); + + Future<({int r, int g, int b})> look() async { + final result = renderer.render( + width: _size, + height: _size, + scene: game.scene, + views: [ + RenderView( + camera: game.camera3d, + clearColor: Vector4(0.0, 0.0, 0.0, 1.0), + ), + ], + settings: const RenderSettings( + tonemap: false, + bloom: BloomSettings(enabled: false), + ), + ); + return _middle(await cpu.device.readPixels(result.frame)); + } + + game.update(0.0); + final first = (await tester.runAsync(look))!; + expect(first.r, greaterThan(150), reason: 'the first frame is red'); + expect(first.b, lessThan(60)); + + game.update(0.5); + final second = (await tester.runAsync(look))!; + expect(second.b, greaterThan(150), reason: 'the second is blue'); + expect(second.r, lessThan(60)); + }); + + testWidgets('billboards handed one atlas share its material and its cards, ' + 'and a one-shot goes when it has played', (tester) async { + // A bank of reeds uploaded its sheet once for each reed. + // + // Mutation: give every billboard its own atlas. + final cpu = cpuTestDevice(width: _size, height: _size); + final game = _Road()..open3d(cpu.device); + final atlas = BillboardAtlas(cpu.device); + late final SpriteBillboardComponent reed; + late final SpriteBillboardComponent other; + late final SpriteBillboardComponent flash; + await tester.runAsync(() async { + await initializeGame(() => game); + final image = await _twoFrames(); + final sprite = Sprite(image, srcSize: Vector2(2.0, 2.0)); + reed = SpriteBillboardComponent( + sprite: sprite, + atlas: atlas, + device: cpu.device, + scene: game.scene, + plane: BridgePlane.ground(), + ); + other = SpriteBillboardComponent( + sprite: sprite, + atlas: atlas, + device: cpu.device, + scene: game.scene, + plane: BridgePlane.ground(), + position: Vector2(3.0, 0.0), + ); + flash = SpriteBillboardComponent( + animation: SpriteAnimation.fromFrameData( + image, + SpriteAnimationData.sequenced( + amount: 2, + stepTime: 0.1, + textureSize: Vector2(2.0, 2.0), + loop: false, + ), + ), + atlas: atlas, + device: cpu.device, + scene: game.scene, + plane: BridgePlane.ground(), + removeOnFinish: true, + ); + game.addAll([reed, other, flash]); + await game.ready(); + }); + + MeshNode cardOf(SpriteBillboardComponent billboard) => + billboard.visual.childrenView.whereType().single; + expect(cardOf(reed).material, same(cardOf(other).material)); + expect(cardOf(reed).mesh, same(cardOf(other).mesh)); + expect(cardOf(flash).material, same(cardOf(reed).material)); + + for (var i = 0; i < 4; i++) { + game.update(0.1); + } + await tester.runAsync(game.ready); + expect(flash.isMounted, isFalse, reason: 'played, and gone'); + expect(reed.isMounted, isTrue); + }); + + testWidgets("a sign says what Flame's text paint wrote, smoothly, and " + 'says something else when handed another sprite', (tester) async { + // Mutation: sample lettering nearest, size the picture without its + // margin, or keep the first sprite's material after the swap. + final cpu = cpuTestDevice(width: _size, height: _size); + final game = _Road()..open3d(cpu.device); + final paint = TextPaint( + style: const TextStyle(fontSize: 32.0, color: ui.Color(0xFFFFFFFF)), + ); + late final Sprite fuel; + late final Sprite empty; + late final SpriteBillboardComponent sign; + await tester.runAsync(() async { + await initializeGame(() => game); + fuel = await BillboardAtlas.spriteOfText('FUEL', paint); + empty = await BillboardAtlas.spriteOfText('EMPTY', paint, margin: 0.0); + sign = SpriteBillboardComponent( + sprite: fuel, + smooth: true, + device: cpu.device, + scene: game.scene, + plane: BridgePlane.ground(), + ); + game.add(sign); + await game.ready(); + }); + + final painter = paint.toTextPainter('FUEL'); + expect(fuel.srcSize.x, (painter.width + 8.0).ceilToDouble()); + expect(fuel.srcSize.y, (painter.height + 8.0).ceilToDouble()); + final pixels = (await tester.runAsync( + () => fuel.image.toByteData(format: ui.ImageByteFormat.rawStraightRgba), + ))!; + expect( + pixels.buffer.asUint8List().where((byte) => byte == 0xFF), + isNotEmpty, + reason: 'the letters are drawn', + ); + + MeshNode card() => sign.visual.childrenView.whereType().single; + final said = card().material; + expect(said.albedoSampler, SamplerOptions.linearClamp); + final wide = card().readScale().x; + + sign.sprite = empty; + await tester.runAsync(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + game.update(0.0); + expect(card().material, isNot(same(said))); + expect(card().material.albedoSampler, SamplerOptions.linearClamp); + expect(card().readScale().x, isNot(closeTo(wide, 1e-6))); + expect(sign.currentSprite, same(empty)); + }); + + testWidgets('an atlas samples one image sharp and smooth for the callers ' + 'that ask for each', (tester) async { + // Mutation: key the materials by the image alone; the second caller + // gets the first caller's sampler. + final cpu = cpuTestDevice(width: _size, height: _size); + final atlas = BillboardAtlas(cpu.device); + final (sharp, smooth) = (await tester.runAsync(() async { + final image = await _twoFrames(); + return ( + await atlas.materialOf(image), + await atlas.materialOf(image, smooth: true), + ); + }))!; + expect(sharp!.albedoSampler, SamplerOptions.nearestClamp); + expect(smooth!.albedoSampler, SamplerOptions.linearClamp); + }); + + testWidgets('an atlas disposed while an image is still being read makes ' + 'no texture for it', (tester) async { + // A billboard removed during its own `onLoad` disposed the atlas under + // an upload, which then made a texture nothing would give back. + // + // Mutation: drop the `_disposed` check after the read. + final cpu = cpuTestDevice(width: _size, height: _size); + final atlas = BillboardAtlas(cpu.device); + final material = await tester.runAsync(() async { + final image = await _twoFrames(); + final pending = atlas.materialOf(image); + atlas.dispose(); + return pending; + }); + expect(material, isNull); + }); +} diff --git a/packages/flame_flutter3d/test/taps3d_test.dart b/packages/flame_flutter3d/test/taps3d_test.dart new file mode 100644 index 00000000000..c04ca7559a3 --- /dev/null +++ b/packages/flame_flutter3d/test/taps3d_test.dart @@ -0,0 +1,270 @@ +/// Taps on what a bridged component draws under a perspective camera, and +/// its hitboxes drawn in the scene where it is. +library; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart' show Anchor, Component; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d { + @override + CameraNode createCamera3d() => + CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.9, far: 200.0), + ) + ..setPosition(0.0, 6.0, 6.0) + ..lookAt(Vector3(0.0, 0.0, -10.0)); +} + +final class _Crate extends Object3dComponent with Tap3dCallbacks { + _Crate(GraphicsDevice device, Scene scene, Vector2 at, {required this.name}) + : super( + node: MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(2.0)).build(), + ), + Material(), + ), + scene: scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + position: at, + ); + + final String name; + int taps = 0; + int ups = 0; + int cancels = 0; + int longs = 0; + + @override + void onTap3d(Vector2 screen) => taps++; + + @override + void onTapUp3d(Vector2 screen) => ups++; + + @override + void onTapCancel3d() => cancels++; + + @override + void onLongTap3d(Vector2 screen) => longs++; +} + +Future<({_World game, CpuDevice device})> _open() async { + final device = CpuDevice( + width: 32, + height: 24, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final game = await initializeGame(_World.new); + game.open3d(device); + return (game: game, device: device); +} + +void main() { + test('a tap on a crate drawn in perspective finds it, where Flame would ' + 'not', () async { + // Mutation: hit-test by the crate's Flame rectangle instead. + final (:game, :device) = await _open(); + final crate = _Crate(device, game.scene, Vector2(0.0, -10.0), name: 'a'); + final taps = Taps3dComponent(); + game.addAll([crate, taps]); + await game.ready(); + game.update(0.0); + + final screen = game.projector.toScreen(crate.scenePosition)!; + expect(taps.nearestAt(screen), same(crate)); + expect( + crate.containsPoint(screen), + isFalse, + reason: "Flame's own test misses the crate the player can see", + ); + expect(taps.nearestAt(Vector2(1.0, 1.0)), isNull); + }); + + test('of two crates under one finger, the nearer hears it', () async { + final (:game, :device) = await _open(); + final far = _Crate(device, game.scene, Vector2(0.0, -12.5), name: 'far'); + final near = _Crate(device, game.scene, Vector2(0.0, -10.0), name: 'near'); + final taps = Taps3dComponent(); + game.addAll([far, near, taps]); + await game.ready(); + game.update(0.0); + + // A point both crates' screen boxes cover: the middle of their overlap. + final a = game.projector.boundsOf(near.node.subtreeBounds!)!; + final b = game.projector.boundsOf(far.node.subtreeBounds!)!; + final left = a.left > b.left ? a.left : b.left; + final right = a.right < b.right ? a.right : b.right; + final top = a.top > b.top ? a.top : b.top; + final bottom = a.bottom < b.bottom ? a.bottom : b.bottom; + expect(left < right && top < bottom, isTrue, reason: 'no overlap to tap'); + final screen = Vector2((left + right) / 2.0, (top + bottom) / 2.0); + expect(far.hitAt3d(screen, game.projector), isTrue); + expect(taps.nearestAt(screen), same(near)); + }); + + test('a hitbox is drawn in the scene, round its component, at its ' + 'height', () async { + final (:game, :device) = await _open(); + final crate = _Crate(device, game.scene, Vector2(3.0, -10.0), name: 'a') + ..elevation = 1.5 + ..size = Vector2(2.0, 2.0) + ..anchor = Anchor.center + ..add(RectangleHitbox()); + game.add(crate); + await game.ready(); + + final lines = DebugDraw(); + addHitboxes3d(lines, game); + expect(lines.lineCount, 4); + // Every end of every edge: on the crate's plane at its height, within a + // metre of its middle across. + final data = lines.vertexBytes.buffer.asFloat32List( + lines.vertexBytes.offsetInBytes, + lines.vertexCount * DebugDraw.floatsPerVertex, + ); + for (var v = 0; v < lines.vertexCount; v++) { + final at = v * DebugDraw.floatsPerVertex; + expect(data[at + 1], closeTo(1.5, 1e-6)); + expect((data[at] - 3.0).abs(), closeTo(1.0, 1e-6)); + expect((data[at + 2] + 10.0).abs(), closeTo(1.0, 1e-6)); + } + }); + + test('a crate on a wide field hears the tap, not the field', () async { + // Measured to the middle of each box, the field's middle was nearer + // the camera than the crate standing on it. + // + // Mutation: rank by the distance to each box's middle. + final (:game, :device) = await _open(); + final field = _Slab(device, game.scene); + final crate = _Crate(device, game.scene, Vector2(0.0, -14.0), name: 'c'); + final taps = Taps3dComponent(); + game.addAll([field, crate, taps]); + await game.ready(); + game.update(0.0); + + final screen = game.projector.toScreen(crate.scenePosition)!; + expect(field.hitAt3d(screen, game.projector), isTrue); + expect(taps.nearestAt(screen), same(crate)); + }); + + test('one instance of a batch is tapped, and its hitbox is drawn', () async { + // Mutation: look for taps and hitboxes on Object3dComponent alone. + final (:game, :device) = await _open(); + final batch = InstancedMeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(2.0)).build()), + Material(), + capacity: 4, + ); + game.scene.add(batch); + final invader = _Invader(batch) + ..position = Vector2(0.0, -10.0) + ..size = Vector2(2.0, 2.0) + ..anchor = Anchor.center + ..add(RectangleHitbox()); + final taps = Taps3dComponent(); + game.addAll([invader, taps]); + await game.ready(); + game.update(0.0); + + final screen = game.projector.toScreen(invader.scenePosition)!; + expect(taps.nearestAt(screen), same(invader)); + final lines = DebugDraw(); + addHitboxes3d(lines, game); + expect(lines.lineCount, 4); + }); + + test('a finger lifted, given up on or held still is told to what it went ' + 'down on', () async { + final (:game, :device) = await _open(); + final crate = _Crate(device, game.scene, Vector2(0.0, -10.0), name: 'a'); + final taps = Taps3dComponent(); + game.addAll([crate, taps]); + await game.ready(); + game.update(0.0); + final screen = game.projector.toScreen(crate.scenePosition)!; + final at = Offset(screen.x, screen.y); + + taps + ..onTapDown(TapDownEvent(1, game, TapDownDetails(globalPosition: at))) + ..onLongTapDown(TapDownEvent(1, game, TapDownDetails(globalPosition: at))) + ..onTapUp( + TapUpEvent( + 1, + game, + TapUpDetails(kind: PointerDeviceKind.touch, globalPosition: at), + ), + ); + expect(crate.taps, 1); + expect(crate.longs, 1); + expect(crate.ups, 1); + + taps + ..onTapDown(TapDownEvent(2, game, TapDownDetails(globalPosition: at))) + ..onTapCancel(TapCancelEvent(2)); + expect(crate.cancels, 1); + }); + + test( + 'a craft seen across the seam of a wrapped world is tapped there', + () async { + // The ghost was a drawing with no component, and a tap on it found + // nothing. + // + // Mutation: test only the craft's own box. + final (:game, :device) = await _open(); + final space = WrapSpace( + min: Vector2(-6.0, -20.0), + max: Vector2(6.0, 0.0), + scene: game.scene, + ); + final crate = _Crate(device, game.scene, Vector2(5.6, -10.0), name: 'a'); + final taps = Taps3dComponent(); + space.add(crate); + game.addAll([space, taps]); + await game.ready(); + game.update(0.0); + + final ghost = space.ghostBoundsOf(crate).single; + final screen = game.projector.toScreen(ghost.center)!; + expect( + game.projector.boundsOf(crate.node.subtreeBounds!)!.left, + greaterThan(screen.x), + reason: 'the tap is on the ghost, not on the craft', + ); + expect(taps.nearestAt(screen), same(crate)); + }, + ); +} + +final class _Slab extends Object3dComponent with Tap3dCallbacks { + _Slab(GraphicsDevice device, Scene scene) + : super( + node: MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(40.0, 0.2, 40.0)).build(), + ), + Material(), + ), + scene: scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + position: Vector2(0.0, -6.0), + ); +} + +final class _Invader extends InstancedObject3dComponent with Tap3dCallbacks { + _Invader(InstancedMeshNode batch) + : super(batch: batch, plane: BridgePlane.ground()); +} diff --git a/packages/flame_flutter3d/test/tiled_world_test.dart b/packages/flame_flutter3d/test/tiled_world_test.dart new file mode 100644 index 00000000000..a4eb797328e --- /dev/null +++ b/packages/flame_flutter3d/test/tiled_world_test.dart @@ -0,0 +1,103 @@ +/// A level drawn in Tiled, stood up in 3D and walked. +library; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tiled/tiled.dart'; + +final class _World extends FlameGame with HasFlutter3d, HasCollisionDetection {} + +/// A five by five maze of 16-pixel tiles: a solid wall layer, a layer of +/// dots, and the player as a point. +const String _level = ''' + + + + + + + + + +1,1,1,1,1, +1,0,0,0,1, +1,0,1,0,1, +1,0,0,0,1, +1,1,1,1,1 + + + + +0,0,0,0,0, +0,0,2,2,0, +0,0,0,2,0, +0,2,2,2,0, +0,0,0,0,0 + + + + + + + + +'''; + +void main() { + test('each tile layer is a grid of blocks where its tiles are, set up by ' + "its properties, and each object is the game's", () async { + // Mutation: read every layer as walls, or leave the objects out. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final spawned = {}; + PositionComponent? player; + final level = TiledWorld3d( + map: TiledMap.parseTmx(_level), + device: device, + scene: game.scene, + plane: BridgePlane.ground(), + material: (_) => engine.Material(), + spawn: (object, at) { + spawned[object.name] = at.clone(); + return player = PositionComponent(position: at); + }, + ); + game.add(level); + await game.ready(); + + final walls = level.grids['walls']!; + final dots = level.grids['dots']!; + expect(walls.grid.count, 17); + expect(walls.grid.isAlive(2, 2), isTrue); + expect(walls.grid.isAlive(1, 1), isFalse); + expect(walls.hitboxes, isTrue, reason: 'solid in Tiled'); + expect(walls.children.whereType(), hasLength(17)); + expect(walls.depth, 0.5); + expect(walls.colour!.z, closeTo(1.0, 1e-9), reason: 'its tint'); + expect(dots.hitboxes, isFalse); + expect(dots.grid.count, 6); + expect(spawned['player'], Vector2(1.5, 1.5)); + + // The player walks the maze Tiled drew, eating the dots it reaches. + final mover = GridMover( + grid: walls, + speed: 4.0, + onArrive: (c, r) => dots.setCell(c, r, alive: false), + )..wanted = GridHeading.right; + player!.add(mover); + await game.ready(); + game.update(0.5); + expect(mover.cell, (3, 1)); + expect(dots.grid.isAlive(2, 1), isFalse, reason: 'eaten'); + expect(dots.grid.count, 4); + }); +} diff --git a/packages/flame_flutter3d/test/trail_component_test.dart b/packages/flame_flutter3d/test/trail_component_test.dart new file mode 100644 index 00000000000..29a8c603328 --- /dev/null +++ b/packages/flame_flutter3d/test/trail_component_test.dart @@ -0,0 +1,91 @@ +/// A line drawn behind a bridged missile, gone with it. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _World extends FlameGame with HasFlutter3d {} + +void main() { + test( + 'a missile lays its trail as it flies, and takes it when it goes', + () async { + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final trail = TrailComponent(spacing: 1.0, length: 4); + final missile = Object3dComponent( + node: SceneNode(), + scene: game.scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + )..add(trail); + game.add(missile); + await game.ready(); + + for (var i = 0; i < 10; i++) { + missile.position.y -= 1.5; + game.update(1 / 60); + } + final line = trail.line!; + expect(line.count, 4, reason: 'a fixed length behind it'); + expect(line.points.last.z, closeTo(missile.position.y, 1.6)); + expect(game.scene.root.childrenView, contains(line)); + + missile.removeFromParent(); + await game.ready(); + expect(game.scene.root.childrenView, isNot(contains(line))); + expect(device.releasedGeometry, isNotEmpty); + }, + ); + + test('a jump across the world breaks the trail rather than drawing a line ' + 'across it', () async { + // Mutation: lay a point wherever the missile is, however far it went. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final trail = TrailComponent(spacing: 1.0, length: 8)..breakAt = 5.0; + final missile = Object3dComponent( + node: SceneNode(), + scene: game.scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + )..add(trail); + game.add(missile); + await game.ready(); + for (var i = 0; i < 3; i++) { + missile.position.x += 1.5; + game.update(1 / 60); + } + missile.position.x = -40.0; + game.update(1 / 60); + final points = trail.line!.points; + expect(points, hasLength(1)); + expect(points.single.x, closeTo(-40.0, 1e-6)); + }); + + test('resized, the line is widened against the new size', () async { + // Mutation: tell the line the size once, on mount. + final device = FakeBackend(); + final game = _World()..open3d(device); + await initializeGame(() => game); + final trail = TrailComponent(); + final missile = Object3dComponent( + node: SceneNode(), + scene: game.scene, + plane: BridgePlane.ground(), + )..add(trail); + game.add(missile); + await game.ready(); + + game.onGameResize(Vector2(1024.0, 512.0)); + final viewport = trail.line!.material.polylineViewport!; + expect(viewport[0], 1024.0); + expect(viewport[1], 512.0); + }); +} diff --git a/packages/flame_flutter3d/test/transparent_flame_game_test.dart b/packages/flame_flutter3d/test/transparent_flame_game_test.dart new file mode 100644 index 00000000000..1bb463e275e --- /dev/null +++ b/packages/flame_flutter3d/test/transparent_flame_game_test.dart @@ -0,0 +1,28 @@ +/// [TransparentFlameGame] does not paint over [Flutter3dFlameWidget]'s 3D +/// layer the way a bare [FlameGame] does. +/// +/// A live bug, not a hypothetical: every page and game built on +/// `Flutter3dFlameWidget` before this class existed drew a solid black +/// rectangle over its own 3D scene, because `GameWidget` paints +/// `Game.backgroundColor()` — opaque black by default — as a `DecoratedBox` +/// behind its own canvas, and that canvas sits on top of `SceneSurface` in +/// the `Stack` this package builds. No widget test caught it: mounting a +/// real `GameWidget` under `flutter_test` hangs in this environment (see +/// `flutter3d_flame_widget_test.dart`), so this checks the one thing that +/// does not need a mounted widget — the colour `GameWidget` would read. +library; + +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('a bare FlameGame paints opaque black, the bug this class fixes', () { + expect(FlameGame().backgroundColor(), const Color(0xFF000000)); + }); + + test('TransparentFlameGame paints nothing behind the 3D layer', () { + expect(TransparentFlameGame().backgroundColor(), const Color(0x00000000)); + }); +} diff --git a/packages/flame_flutter3d/test/wrap_space_test.dart b/packages/flame_flutter3d/test/wrap_space_test.dart new file mode 100644 index 00000000000..252b048aef3 --- /dev/null +++ b/packages/flame_flutter3d/test/wrap_space_test.dart @@ -0,0 +1,208 @@ +/// A world whose edges meet: positions wrap, a craft by an edge is drawn on +/// the other side too, and it can be hit across the seam. +library; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Game extends FlameGame with HasCollisionDetection {} + +final class _Rock extends Object3dComponent with CollisionCallbacks { + _Rock(Scene scene, Vector2 at) + : super( + node: MeshNode( + CpuMesh(CuboidShape(size: Vector3.all(1.0)).build()), + engine.Material(), + ), + scene: scene, + plane: BridgePlane.ground(), + direction: SyncDirection.flameToScene, + position: at, + size: Vector2.all(1.0), + anchor: Anchor.center, + children: [RectangleHitbox()], + ); + + PositionComponent? hitBy; + int starts = 0; + + @override + void onCollisionStart(List points, PositionComponent other) { + super.onCollisionStart(points, other); + hitBy = other; + starts++; + } +} + +WrapSpace _space(Scene scene) => WrapSpace( + min: Vector2(-10.0, -10.0), + max: Vector2(10.0, 10.0), + scene: scene, +); + +void main() { + testWithGame<_Game>( + 'what leaves by one edge comes back by the other', + _Game.new, + (game) async { + final scene = Scene(); + final space = _space(scene); + final rock = _Rock(scene, Vector2(10.5, -3.0)); + space.add(rock); + game.add(space); + await game.ready(); + game.update(1 / 60); + expect(rock.position.x, closeTo(-9.5, 1e-9)); + expect( + space.shortestWay(Vector2(9.0, 0.0), Vector2(-9.0, 0.0)).x, + closeTo(2.0, 1e-9), + ); + }, + ); + + testWithGame<_Game>( + 'a rock by the edge is drawn on the other side as well', + _Game.new, + (game) async { + // Mutation: draw no ghost; the rock blinks from side to side. + final scene = Scene(); + final space = _space(scene); + final rock = _Rock(scene, Vector2(9.7, 0.0)); + space.add(rock); + game.add(space); + await game.ready(); + game.update(1 / 60); + + final xs = [ + for (final node in scene.root.childrenView) node.readPosition().x, + ]..sort(); + expect(xs.first, closeTo(9.7 - 20.0, 1e-4), reason: 'the ghost'); + expect(xs.last, closeTo(9.7, 1e-4), reason: 'the rock'); + }, + ); + + testWithGame<_Game>( + 'a shot on one side hits a rock on the other, across the seam', + _Game.new, + (game) async { + // Mutation: give the ghosts no hitboxes. + final scene = Scene(); + final space = _space(scene); + final rock = _Rock(scene, Vector2(9.8, 0.0)); + final shot = _Rock(scene, Vector2(-9.9, 0.0)); + space.addAll([rock, shot]); + game.add(space); + await game.ready(); + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + await game.ready(); + } + expect(rock.hitBy, same(shot)); + }, + ); + + for (final (name, a, b) in <(String, Vector2, Vector2)>[ + ('by the same edge', Vector2(9.6, 0.0), Vector2(9.9, 0.3)), + ('across the seam', Vector2(9.8, 0.0), Vector2(-9.9, 0.0)), + ('in opposite corners', Vector2(9.8, 9.8), Vector2(-9.9, -9.9)), + ]) { + testWithGame<_Game>('two rocks $name are told they met once', _Game.new, ( + game, + ) async { + // Two by the same edge met really and through their ghosts, two across + // the seam through each one's ghost, and every hit counted twice. + // + // Mutation: let ghosts meet ghosts, and both mirrored ghosts meet. + final scene = Scene(); + final space = _space(scene); + final first = _Rock(scene, a); + final second = _Rock(scene, b); + space.addAll([first, second]); + game.add(space); + await game.ready(); + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + await game.ready(); + } + expect(first.starts, 1); + expect(second.starts, 1); + }); + } + + testWithGame<_Game>( + 'a body the physics moves is carried across the edge, still moving', + _Game.new, + (game) async { + // Its Flame position was wrapped and read straight back from the body + // on the far side, and it flew on out of the world. + // + // Mutation: wrap the Flame position alone. + final world = CollisionWorld(); + final body = RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.4)), + position: Vector3(10.5, 0.0, -3.0), + )..velocity.setValues(4.0, 0.0, 0.0); + final ship = RigidBodyComponent( + body: body, + node: SceneNode(), + scene: Scene(), + plane: BridgePlane.ground(), + ); + final space = _space(ship.scene); + space.add(ship); + game.add(space); + await game.ready(); + game + ..update(1 / 60) + ..update(1 / 60); + + expect(body.position.x, closeTo(-9.5, 1e-6)); + expect(ship.position.x, closeTo(-9.5, 1e-6)); + expect(body.velocity.x, 4.0, reason: 'carried, not stopped'); + }, + ); + + testWithGame<_Game>( + "a passive rock's ghost is passive, and a polygon stays a polygon", + _Game.new, + (game) async { + final scene = Scene(); + final space = _space(scene); + final rock = _Rock(scene, Vector2(9.7, 0.0)); + rock.children.whereType().single.collisionType = + CollisionType.passive; + rock.add( + PolygonHitbox([ + Vector2(0.0, 0.0), + Vector2(1.0, 0.0), + Vector2(0.5, 1.0), + ]), + ); + space.add(rock); + game.add(space); + await game.ready(); + game.update(1 / 60); + await game.ready(); + + final hitboxes = rock.children.whereType().toList(); + expect(hitboxes, hasLength(4), reason: 'two, and a ghost of each'); + expect( + hitboxes.whereType(), + hasLength(2), + reason: 'the ghost of a triangle is a triangle', + ); + expect( + hitboxes.whereType().map((h) => h.collisionType), + everyElement(CollisionType.passive), + ); + }, + ); +} From 576c5c579bfc9f00bfa600f0583603ee80bfa07e Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Thu, 1 Oct 2026 18:02:51 +0300 Subject: [PATCH 2/4] docs: Add flame_flutter3d stories to the examples Three stories: post-processing toggled at runtime under a Flame HUD, one game loop driving both engines, and a Tiled map stood up in 3D. Each shows which backend draws it, WebGL2 or WebGPU in a browser. --- examples/assets/tiles/maze_3d.tmx | 61 ++++ examples/lib/main.dart | 2 + .../flame_flutter3d/flame_flutter3d.dart | 39 +++ .../post_processing_example.dart | 326 ++++++++++++++++++ .../flame_flutter3d/shared_loop_example.dart | 209 +++++++++++ .../flame_flutter3d/story_host.dart | 62 ++++ .../flame_flutter3d/tiled_maze_example.dart | 144 ++++++++ examples/pubspec.yaml | 5 + 8 files changed, 848 insertions(+) create mode 100644 examples/assets/tiles/maze_3d.tmx create mode 100644 examples/lib/stories/bridge_libraries/flame_flutter3d/flame_flutter3d.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_flutter3d/post_processing_example.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_flutter3d/shared_loop_example.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_flutter3d/story_host.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_flutter3d/tiled_maze_example.dart diff --git a/examples/assets/tiles/maze_3d.tmx b/examples/assets/tiles/maze_3d.tmx new file mode 100644 index 00000000000..c45ba2470fb --- /dev/null +++ b/examples/assets/tiles/maze_3d.tmx @@ -0,0 +1,61 @@ + + + + + + + + + + +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1, +1,1,1,1,1,1,1,1,1 + + + + + + + + +2,2,2,2,2,2,2,2,2, +2,0,0,0,0,0,0,0,2, +2,0,2,2,0,2,2,0,2, +2,0,0,0,0,0,0,0,2, +2,2,0,2,0,2,0,2,2, +2,0,0,0,0,0,0,0,2, +2,0,2,2,0,2,2,0,2, +2,0,0,0,0,0,0,0,2, +2,2,2,2,2,2,2,2,2 + + + + + + + + +0,0,0,0,0,0,0,0,0, +0,3,3,3,3,3,3,3,0, +0,3,0,0,3,0,0,3,0, +0,3,3,3,3,3,3,3,0, +0,0,3,0,3,0,3,0,0, +0,3,3,3,3,3,3,3,0, +0,3,0,0,3,0,0,3,0, +0,3,3,3,0,3,3,3,0, +0,0,0,0,0,0,0,0,0 + + + + + + + + diff --git a/examples/lib/main.dart b/examples/lib/main.dart index e0d3918ec2e..b1f87ffbbc3 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -3,6 +3,7 @@ import 'package:examples/platform/stub_provider.dart' if (dart.library.html) 'platform/web_provider.dart'; import 'package:examples/stories/animations/animations.dart'; import 'package:examples/stories/bridge_libraries/audio/audio.dart'; +import 'package:examples/stories/bridge_libraries/flame_flutter3d/flame_flutter3d.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart'; @@ -116,6 +117,7 @@ void runAsWidgetbook() { imageStories(), // Bridge package examples + flameFlutter3dStories(), forge2DStories(), jointsStories(), flameIsolateStories(), diff --git a/examples/lib/stories/bridge_libraries/flame_flutter3d/flame_flutter3d.dart b/examples/lib/stories/bridge_libraries/flame_flutter3d/flame_flutter3d.dart new file mode 100644 index 00000000000..319afec9f69 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_flutter3d/flame_flutter3d.dart @@ -0,0 +1,39 @@ +import 'package:examples/commons/commons.dart'; +import 'package:examples/commons/example_use_case.dart'; +import 'package:examples/stories/bridge_libraries/flame_flutter3d/post_processing_example.dart'; +import 'package:examples/stories/bridge_libraries/flame_flutter3d/shared_loop_example.dart'; +import 'package:examples/stories/bridge_libraries/flame_flutter3d/story_host.dart'; +import 'package:examples/stories/bridge_libraries/flame_flutter3d/tiled_maze_example.dart'; +import 'package:widgetbook/widgetbook.dart'; + +String _link(String example) => + baseLink('bridge_libraries/flame_flutter3d/$example'); + +WidgetbookComponent flameFlutter3dStories() { + return WidgetbookComponent( + name: 'flame_flutter3d', + useCases: [ + ExampleUseCase( + name: 'Post-processing', + builder: (_) => const Flutter3dStory( + create: PostProcessingExample.new, + overlays: {'panel': PostProcessingExample.panel}, + ), + codeLink: _link('post_processing_example.dart'), + info: PostProcessingExample.description, + ), + ExampleUseCase( + name: 'One loop for both engines', + builder: (_) => const Flutter3dStory(create: SharedLoopExample.new), + codeLink: _link('shared_loop_example.dart'), + info: SharedLoopExample.description, + ), + ExampleUseCase( + name: 'Tiled map in 3D', + builder: (_) => const Flutter3dStory(create: TiledMazeExample.new), + codeLink: _link('tiled_maze_example.dart'), + info: TiledMazeExample.description, + ), + ], + ); +} diff --git a/examples/lib/stories/bridge_libraries/flame_flutter3d/post_processing_example.dart b/examples/lib/stories/bridge_libraries/flame_flutter3d/post_processing_example.dart new file mode 100644 index 00000000000..1aac15f14d5 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_flutter3d/post_processing_example.dart @@ -0,0 +1,326 @@ +import 'dart:math' as math; + +import 'package:examples/stories/bridge_libraries/flame_flutter3d/story_host.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; + +class PostProcessingExample extends FlameGame with HasFlutter3d { + static const String description = ''' + A lit 3D scene drawn by flutter3d under a Flame HUD, with the + post-processing of the frame switched on and off from the panel: bloom, + ambient occlusion, screen-space reflections, depth of field, motion + blur, volumetric fog with light shafts, the tone mapping curve, exposure + and temporal anti-aliasing. + + The ring of cubes is turned by a Flame effect, so motion blur has + something to smear. On the web the frame is drawn through WebGL2, or + through WebGPU in a build made with + `--dart-define=FLUTTER3D_WEBGPU=true`; the HUD says which. + '''; + + /// What the panel switches; read for every frame. + final PostProcessingOptions options = PostProcessingOptions(); + + static final Vector3 _focus = Vector3(0, 0.8, 0); + + @override + CameraNode createCamera3d() => + CameraNode( + name: 'camera', + projection: const PerspectiveProjection(fovYRadians: 0.8, far: 200), + ) + ..setPosition(0, 3.2, 8.5) + ..lookAt(_focus); + + @override + RenderSettings renderSettings() => options.settings( + focusDistance: camera3d.readWorldPosition().distanceTo(_focus), + ); + + @override + void onOpen3d() { + clearColor.setValues(0.02, 0.025, 0.04, 1); + + MeshNode mesh(Shape shape, engine.Material material) => + MeshNode(DeviceMesh.upload(device, shape.build()), material); + + scene + ..add( + mesh( + const PlaneShape(width: 200, depth: 200), + engine.Material( + name: 'floor', + baseColor: Vector4(0.18, 0.19, 0.22, 1), + metallic: 0.2, + roughness: 0.25, + ), + ), + ) + ..add( + LightNode(intensity: 2.2, color: Vector3(1, 0.92, 0.8)) + ..setLocalForward(Vector3(-0.5, -0.6, -0.8)), + ) + ..add( + LightNode( + type: LightType.point, + intensity: 30, + range: 12, + color: Vector3(0.3, 0.6, 1), + )..setPosition(-3, 2.5, 1), + ); + + // A row of spheres from rough plastic to polished metal. + for (var i = 0; i < 5; i++) { + final t = i / 4; + scene.add( + mesh( + const SphereShape(radius: 0.55), + engine.Material( + name: 'sphere $i', + baseColor: Vector4(0.9, 0.55 + 0.3 * t, 0.3, 1), + metallic: t, + roughness: 0.85 - 0.75 * t, + ), + )..setPosition(-3 + 1.5 * i, 0.55, -1.5), + ); + } + + // Glowing shapes, far brighter than white, for the bloom to catch. + scene + ..add( + mesh( + const TorusShape(radius: 0.7, tubeRadius: 0.12), + engine.Material( + name: 'ring light', + baseColor: Vector4(0.1, 0.1, 0.1, 1), + emissive: Vector3(1, 0.35, 0.1), + emissiveStrength: 12, + ), + )..setPosition(0, 1.6, -3.5), + ) + ..add( + mesh( + CuboidShape(size: Vector3(0.3, 1.6, 0.3)), + engine.Material( + name: 'pillar light', + baseColor: Vector4(0.1, 0.1, 0.1, 1), + emissive: Vector3(0.2, 0.7, 1), + emissiveStrength: 8, + ), + )..setPosition(3.5, 0.8, -3), + ); + + // A ring of cubes on a node Flame turns, through its own effects. + final ring = SceneNode(name: 'ring'); + final cube = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(0.45)).build(), + ); + final cubeMaterial = engine.Material( + name: 'cube', + baseColor: Vector4(0.85, 0.85, 0.9, 1), + metallic: 0.6, + roughness: 0.3, + ); + for (var i = 0; i < 8; i++) { + final angle = i * math.pi / 4; + ring.add( + MeshNode(cube, cubeMaterial) + ..setPosition(1.6 * math.cos(angle), 0, 1.6 * math.sin(angle)), + ); + } + add( + Node3dComponent( + node: ring, + scene: scene, + position: Vector3(0, 0.45, 1.2), + )..add( + Rotate3dEffect.by( + Vector3(0, 1, 0), + 2 * math.pi, + EffectController(duration: 2.5, infinite: true), + ), + ), + ); + + add( + TextComponent( + text: 'Drawn with ${backendName(device)}', + position: Vector2.all(16), + ), + ); + } + + /// The panel of switches over the game. + static Widget panel(BuildContext context, PostProcessingExample game) => + _Panel(options: game.options); +} + +/// The switches of [PostProcessingExample], read into a [RenderSettings] +/// each frame. +class PostProcessingOptions { + bool bloom = true; + bool ambientOcclusion = true; + bool groundTruthOcclusion = false; + bool reflections = true; + bool depthOfField = false; + bool motionBlur = false; + bool volumetricFog = false; + bool autoExposure = false; + bool temporalAntiAlias = true; + double exposure = RenderSettings.defaultExposure; + TonemapCurve tonemapCurve = TonemapCurve.aces; + + static const List curves = [ + TonemapCurve.neutral, + TonemapCurve.aces, + TonemapCurve.agx, + TonemapCurve.reinhard, + ]; + + RenderSettings settings({required double focusDistance}) => RenderSettings( + exposure: exposure, + tonemapCurve: tonemapCurve, + bloom: BloomSettings(enabled: bloom, intensity: 0.08), + ambientOcclusion: AmbientOcclusionSettings( + enabled: ambientOcclusion, + method: groundTruthOcclusion + ? AmbientOcclusionMethod.gtao + : AmbientOcclusionMethod.ssao, + ), + reflections: ReflectionSettings(enabled: reflections), + depthOfField: DepthOfFieldSettings( + enabled: depthOfField, + focusDistance: focusDistance, + aperture: 2, + ), + motionBlur: MotionBlurSettings(enabled: motionBlur), + volumetricFog: VolumetricFogSettings( + enabled: volumetricFog, + density: 0.06, + ), + lightShafts: LightShaftSettings(enabled: volumetricFog), + autoExposure: AutoExposureSettings(enabled: autoExposure), + antiAlias: AntiAliasSettings( + enabled: temporalAntiAlias, + temporal: TemporalSettings(enabled: temporalAntiAlias), + ), + ); +} + +class _Panel extends StatefulWidget { + const _Panel({required this.options}); + + final PostProcessingOptions options; + + @override + State<_Panel> createState() => _PanelState(); +} + +class _PanelState extends State<_Panel> { + PostProcessingOptions get _options => widget.options; + + /// Each switch: its label, whether it is on, and how it is set. + List<(String, bool, ValueChanged)> get _switches => [ + ('Bloom', _options.bloom, (on) => _options.bloom = on), + ( + 'Ambient occlusion', + _options.ambientOcclusion, + (on) => _options.ambientOcclusion = on, + ), + ( + 'GTAO', + _options.groundTruthOcclusion, + (on) => _options.groundTruthOcclusion = on, + ), + ('Reflections', _options.reflections, (on) => _options.reflections = on), + ( + 'Depth of field', + _options.depthOfField, + (on) => _options.depthOfField = on, + ), + ('Motion blur', _options.motionBlur, (on) => _options.motionBlur = on), + ( + 'Fog and light shafts', + _options.volumetricFog, + (on) => _options.volumetricFog = on, + ), + ( + 'Auto exposure', + _options.autoExposure, + (on) => _options.autoExposure = on, + ), + ( + 'Temporal AA', + _options.temporalAntiAlias, + (on) => _options.temporalAntiAlias = on, + ), + ]; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomLeft, + child: Container( + margin: const EdgeInsets.all(12), + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(maxWidth: 560), + decoration: BoxDecoration( + color: const Color(0xCC101318), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final (label, value, set) in _switches) + FilterChip( + label: Text(label), + selected: value, + onSelected: (on) => setState(() => set(on)), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + children: [ + for (final curve in PostProcessingOptions.curves) + ChoiceChip( + label: Text(curve.name), + selected: _options.tonemapCurve == curve, + onSelected: (_) => + setState(() => _options.tonemapCurve = curve), + ), + ], + ), + Row( + children: [ + const Text('Exposure', style: TextStyle(color: Colors.white)), + Expanded( + child: Slider( + value: _options.exposure, + min: 0.25, + max: 4, + onChanged: _options.autoExposure + ? null + : (value) => setState(() => _options.exposure = value), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/examples/lib/stories/bridge_libraries/flame_flutter3d/shared_loop_example.dart b/examples/lib/stories/bridge_libraries/flame_flutter3d/shared_loop_example.dart new file mode 100644 index 00000000000..b8328332123 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_flutter3d/shared_loop_example.dart @@ -0,0 +1,209 @@ +import 'dart:math' as math; + +import 'package:examples/stories/bridge_libraries/flame_flutter3d/story_host.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +class SharedLoopExample extends FlameGame with HasFlutter3d { + static const String description = ''' + Flame and flutter3d on one clock. The crates are Flame components: their + `MoveEffect`, `RotateEffect` and `ScaleEffect` are Flame's own, and each + frame the bridge writes where Flame put them into the 3D scene, after + the effects have run. + + Tap a crate: the tap is tested against what is drawn in perspective, + not against Flame's flat rectangle, and the crate flashes through a + `TintEffect`. The falling box is simulated by flutter3d's physics, + stepped as a Flame component, and its landing on the pad reaches Flame + as an ordinary `onCollisionStart`. + '''; + + static final BridgePlane floor = BridgePlane.ground(height: 0.5); + + final CollisionWorld collisionWorld = CollisionWorld(); + late final Dynamics dynamics = Dynamics(world: collisionWorld); + + final TextComponent hud = TextComponent(position: Vector2.all(16)); + + int taps = 0; + int landings = 0; + + @override + CameraNode createCamera3d() => + CameraNode( + name: 'camera', + projection: const PerspectiveProjection(fovYRadians: 0.8, far: 200), + ) + ..setPosition(0, 6, 8) + ..lookAt(Vector3(0, 0, -1)); + + @override + void onOpen3d() { + clearColor.setValues(0.55, 0.7, 0.85, 1); + MeshNode mesh(Shape shape, Vector4 color) => MeshNode( + DeviceMesh.upload(device, shape.build()), + engine.Material(name: 'mesh', baseColor: color, roughness: 0.6), + ); + + scene + ..add( + mesh( + const PlaneShape(width: 16, depth: 16), + Vector4(0.55, 0.6, 0.5, 1), + ), + ) + ..add( + LightNode(intensity: 2.5)..setLocalForward(Vector3(-0.4, -1, -0.3)), + ); + + // Three crates moved by Flame's effects alone. + final crates = [ + for (var i = 0; i < 3; i++) + _Crate( + node: mesh(CuboidShape(size: Vector3.all(1)), _crateColor), + scene: scene, + plane: floor, + position: Vector2(-3 + 3.0 * i, -3), + onTapped: _onTapped, + ), + ]; + crates[0].add( + MoveEffect.by( + Vector2(0, 3), + EffectController(duration: 1.5, alternate: true, infinite: true), + ), + ); + crates[1].add( + RotateEffect.by( + 2 * math.pi, + EffectController(duration: 3, infinite: true), + ), + ); + crates[2].add( + ScaleEffect.to( + Vector2.all(1.6), + EffectController(duration: 0.8, alternate: true, infinite: true), + ), + ); + + // The physics: a floor, a trigger pad on it, and a box dropped onto it. + collisionWorld.addBox(Vector3(0, -0.5, 0), Vector3(16, 1, 16)); + final pad = collisionWorld.add( + Collider( + shape: CollisionBox(Vector3(1, 0.05, 1)), + position: Vector3(0, 0.05, 1.5), + kind: ColliderKind.trigger, + ), + ); + final padNode = mesh( + CuboidShape(size: Vector3(2, 0.05, 2)), + Vector4(0.35, 0.4, 0.5, 1), + )..setPosition(0, 0.025, 1.5); + scene.add(padNode); + + final box = dynamics.add( + RigidBody( + world: collisionWorld, + shape: CollisionBox(Vector3.all(0.3)), + position: Vector3(0, 3, 1.5), + ), + ); + final boxComponent = _FallingBox( + body: box, + node: mesh( + CuboidShape(size: Vector3.all(0.6)), + Vector4(0.9, 0.5, 0.2, 1), + ), + scene: scene, + plane: floor, + onLanded: _onLanded, + ); + CollisionBridge( + collider: box.collider, + component: boxComponent, + resolveOther: (other) => other == pad ? crates[1] : null, + ); + + addAll([ + PhysicsStepComponent(dynamics: dynamics, world: collisionWorld), + ...crates, + boxComponent, + Taps3dComponent(), + hud, + ]); + _updateHud(); + } + + static final Vector4 _crateColor = Vector4(0.75, 0.55, 0.35, 1); + + void _onTapped(_Crate crate) { + taps++; + crate.add( + TintEffect( + Vector4(1, 0.25, 0.25, 1), + EffectController(duration: 0.15, alternate: true), + ), + ); + _updateHud(); + } + + void _onLanded() { + landings++; + _updateHud(); + } + + void _updateHud() { + hud.text = + 'Drawn with ${backendName(device)}. ' + 'Taps: $taps. Landings heard by Flame: $landings.'; + } +} + +class _Crate extends Object3dComponent with Tap3dCallbacks { + _Crate({ + required super.node, + required super.scene, + required super.plane, + required super.position, + required this.onTapped, + }) : super( + direction: SyncDirection.flameToScene, + size: Vector2.all(1), + anchor: Anchor.center, + ); + + final void Function(_Crate crate) onTapped; + + @override + void onTap3d(Vector2 screen) => onTapped(this); +} + +/// A box the physics drops; it is dropped again a while after each landing. +class _FallingBox extends RigidBodyComponent { + _FallingBox({ + required super.body, + required super.node, + required super.scene, + required super.plane, + required this.onLanded, + }); + + final void Function() onLanded; + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + onLanded(); + add(TimerComponent(period: 1.5, removeOnFinish: true, onTick: _drop)); + } + + void _drop() => teleport(Vector3(0, 3, 1.5)); +} diff --git a/examples/lib/stories/bridge_libraries/flame_flutter3d/story_host.dart b/examples/lib/stories/bridge_libraries/flame_flutter3d/story_host.dart new file mode 100644 index 00000000000..3c1eb430fa2 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_flutter3d/story_host.dart @@ -0,0 +1,62 @@ +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart' show DepthRange, GraphicsDevice; +import 'package:flutter3d_cpu/flutter3d_cpu.dart' show CpuDevice; + +/// Shows a game with a 3D layer, and lets its device go when the story is +/// left: the world lives as long as the game, not the widget, so without +/// [HasFlutter3d.dispose] every visit to a story would keep a GPU context. +class Flutter3dStory extends StatefulWidget { + const Flutter3dStory({ + required this.create, + this.overlays = const {}, + super.key, + }); + + final G Function() create; + + /// Flutter widgets over the game, shown from the start. + final Map overlays; + + @override + State> createState() => _Flutter3dStoryState(); +} + +class _Flutter3dStoryState + extends State> { + late final G _game = widget.create(); + + @override + void dispose() { + _game.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Flutter3dFlameWidget( + game: _game, + overlayBuilderMap: { + for (final MapEntry(:key, :value) in widget.overlays.entries) + key: (context, Game game) => value(context, game as G), + }, + initialActiveOverlays: widget.overlays.keys.toList(), + ); + } +} + +/// Which graphics API the 3D layer opened on. +/// +/// flutter3d does not name its devices, so this tells them apart by what +/// they are: on the web WebGL2 is the one with OpenGL's depth range, and on +/// a native build the software rasterizer is the one that is not Impeller. +String backendName(GraphicsDevice device) { + if (kIsWeb) { + return device.depthRange == DepthRange.negativeOneToOne + ? 'WebGL2' + : 'WebGPU'; + } + return device is CpuDevice ? 'CPU' : 'Impeller'; +} diff --git a/examples/lib/stories/bridge_libraries/flame_flutter3d/tiled_maze_example.dart b/examples/lib/stories/bridge_libraries/flame_flutter3d/tiled_maze_example.dart new file mode 100644 index 00000000000..1e2d2f65870 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_flutter3d/tiled_maze_example.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math; + +import 'package:examples/stories/bridge_libraries/flame_flutter3d/story_host.dart'; +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:tiled/tiled.dart'; + +class TiledMazeExample extends FlameGame + with HasFlutter3d, HasCollisionDetection { + static const String description = ''' + A maze drawn in the Tiled editor and stood up in 3D by `TiledWorld3d`. + Each tile layer becomes a grid of blocks set up by its custom properties + in Tiled: the walls are tall and solid, so they get Flame hitboxes, the + floor is merged into one mesh, and the dots float above it. + + The player is the map's one object. A `GridMover` walks it from the + middle of one cell to the next, turning at random at the junctions and + eating the dots, while the camera circles the maze. + '''; + + static final BridgePlane _plane = BridgePlane.ground(); + final math.Random _random = math.Random(4); + + late final TiledMap _map; + late final Vector3 _centre; + double _time = 0; + + @override + CameraNode createCamera3d() => CameraNode( + name: 'camera', + projection: const PerspectiveProjection(fovYRadians: 0.8, far: 200), + ); + + @override + Future onLoad() async { + _map = TiledMap.parseTmx(await assets.readFile('assets/tiles/maze_3d.tmx')); + await super.onLoad(); + } + + @override + void onOpen3d() { + clearColor.setValues(0.04, 0.04, 0.08, 1); + scene.add( + LightNode(intensity: 2.5)..setLocalForward(Vector3(-0.3, -1, -0.5)), + ); + _centre = _plane.to3d(Vector2(_map.width / 2, _map.height / 2)); + + late final TiledWorld3d level; + level = TiledWorld3d( + map: _map, + device: device, + scene: scene, + plane: _plane, + material: (layer) => engine.Material( + name: layer.name, + roughness: layer.name == 'walls' ? 0.35 : 0.8, + metallic: layer.name == 'walls' ? 0.4 : 0, + ), + spawn: (object, at) => + object.name == 'player' ? _player(level, at) : null, + ); + addAll([ + level, + TextComponent( + text: 'Drawn with ${backendName(device)}', + position: Vector2.all(16), + ), + ]); + } + + PositionComponent _player(TiledWorld3d level, Vector2 at) { + final walls = level.grids['walls']!; + final dots = level.grids['dots']!; + late final GridMover mover; + mover = GridMover( + grid: walls, + speed: 3, + onArrive: (column, row) { + dots.setCell(column, row, alive: false); + mover.wanted = _turnAt(walls, column, row, mover.heading); + }, + )..wanted = GridHeading.left; + return Object3dComponent( + node: MeshNode( + DeviceMesh.upload(device, const SphereShape(radius: 0.35).build()), + engine.Material( + name: 'player', + baseColor: Vector4(1, 0.85, 0.2, 1), + emissive: Vector3(1, 0.7, 0.1), + emissiveStrength: 2, + ), + ), + scene: scene, + plane: _plane, + direction: SyncDirection.flameToScene, + elevation: 0.35, + position: at, + size: Vector2.all(0.7), + anchor: Anchor.center, + children: [CircleHitbox(), mover], + ); + } + + /// A way open from the cell, not straight back unless it is a dead end. + GridHeading _turnAt( + CellGridComponent walls, + int column, + int row, + GridHeading heading, + ) { + final open = [ + for (final way in GridHeading.values) + if (way != GridHeading.none && + !walls.grid.isAlive(column + way.dx, row + way.dy)) + way, + ]; + final onward = open.where((way) => way != heading.opposite).toList(); + final choices = onward.isEmpty ? open : onward; + return choices.isEmpty + ? GridHeading.none + : choices[_random.nextInt(choices.length)]; + } + + @override + void update(double dt) { + super.update(dt); + if (!has3d) { + return; + } + _time += dt; + final angle = _time * 0.25; + camera3d + ..setPosition( + _centre.x + 9 * math.cos(angle), + 9, + _centre.z + 9 * math.sin(angle), + ) + ..lookAt(_centre); + } +} diff --git a/examples/pubspec.yaml b/examples/pubspec.yaml index 433bb19b9ac..f5221c2f0c4 100644 --- a/examples/pubspec.yaml +++ b/examples/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: crystal_ball: ^0.1.0 flame: ^2.0.0-dev.0 flame_audio: ^2.13.0-dev.0 + flame_flutter3d: ^0.9.0-dev.0 flame_forge2d: ^0.21.0-dev.0 flame_isolate: ^0.7.0-dev.0+24 flame_lottie: ^0.5.0-dev.0+24 @@ -25,6 +26,9 @@ dependencies: flame_tiled: ^4.0.0-dev.0 flutter: sdk: flutter + flutter3d: ^0.8.3 + flutter3d_cpu: ^0.8.0 + flutter3d_sim: ^0.8.1 google_fonts: ^8.0.2 jenny: ^1.5.2-dev.0 material_ui: ^1.0.0 @@ -32,6 +36,7 @@ dependencies: padracing: ^1.0.0 provider: ^6.1.2 rogue_shooter: ^0.1.0 + tiled: ^0.12.0 trex_game: ^0.1.0 url_launcher: ^6.3.0 web: ^1.1.0 From 449d8c58761960130dd5fdd6a7b9f97233b57654 Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Thu, 1 Oct 2026 18:02:51 +0300 Subject: [PATCH 3/4] docs: Add River Sortie, a flame_flutter3d game, to the examples A River Raid-style game built on the bridge: Flame components and effects, flutter3d physics, particles and a chase camera, on one loop. This copy is silent, since a real audio backend would bring SoLoud's native build into the workspace. --- examples/games/river_sortie/README.md | 125 +++ .../games/river_sortie/analysis_options.yaml | 11 + .../river_sortie/assets/models/LICENSES.md | 47 + .../assets/models/Textures/colormap.png | Bin 0 -> 8814 bytes .../river_sortie/assets/models/helicopter.glb | Bin 0 -> 77712 bytes .../river_sortie/assets/models/jet_enemy.glb | Bin 0 -> 140296 bytes .../river_sortie/assets/models/jet_player.glb | Bin 0 -> 21652 bytes .../river_sortie/assets/models/tanker_a.glb | Bin 0 -> 98052 bytes .../river_sortie/assets/models/tanker_b.glb | Bin 0 -> 79844 bytes examples/games/river_sortie/lib/main.dart | 4 + .../games/river_sortie/lib/river_sortie.dart | 98 ++ .../river_sortie/lib/src/audio/audio.dart | 20 + .../lib/src/audio/audio_scene_component.dart | 222 +++++ .../src/audio/sound_emitter_component.dart | 128 +++ .../games/river_sortie/lib/src/course.dart | 435 +++++++++ .../games/river_sortie/lib/src/craft.dart | 142 +++ examples/games/river_sortie/lib/src/hud.dart | 227 +++++ .../games/river_sortie/lib/src/levels.dart | 225 +++++ .../games/river_sortie/lib/src/models.dart | 447 +++++++++ .../games/river_sortie/lib/src/pieces.dart | 505 ++++++++++ .../river_sortie/lib/src/river_game.dart | 884 ++++++++++++++++++ .../games/river_sortie/lib/src/rules.dart | 92 ++ .../games/river_sortie/lib/src/sounds.dart | 171 ++++ .../games/river_sortie/lib/src/sprites.dart | 193 ++++ .../games/river_sortie/lib/src/staging.dart | 329 +++++++ examples/games/river_sortie/pubspec.yaml | 66 ++ .../river_sortie/test/billboards_test.dart | 214 +++++ .../games/river_sortie/test/course_test.dart | 136 +++ .../games/river_sortie/test/frame_test.dart | 157 ++++ .../games/river_sortie/test/game_test.dart | 438 +++++++++ .../games/river_sortie/test/levels_test.dart | 81 ++ .../games/river_sortie/test/release_test.dart | 49 + .../games/river_sortie/test/rules_test.dart | 58 ++ .../games/river_sortie/test/sound_test.dart | 181 ++++ .../games/river_sortie/test/touch_test.dart | 78 ++ examples/games/river_sortie/web/favicon.png | Bin 0 -> 1751 bytes .../games/river_sortie/web/icons/Icon-192.png | Bin 0 -> 16446 bytes .../games/river_sortie/web/icons/Icon-512.png | Bin 0 -> 45848 bytes .../web/icons/Icon-maskable-192.png | Bin 0 -> 16446 bytes .../web/icons/Icon-maskable-512.png | Bin 0 -> 45848 bytes examples/games/river_sortie/web/index.html | 47 + examples/games/river_sortie/web/manifest.json | 35 + 42 files changed, 5845 insertions(+) create mode 100644 examples/games/river_sortie/README.md create mode 100644 examples/games/river_sortie/analysis_options.yaml create mode 100644 examples/games/river_sortie/assets/models/LICENSES.md create mode 100644 examples/games/river_sortie/assets/models/Textures/colormap.png create mode 100644 examples/games/river_sortie/assets/models/helicopter.glb create mode 100644 examples/games/river_sortie/assets/models/jet_enemy.glb create mode 100644 examples/games/river_sortie/assets/models/jet_player.glb create mode 100644 examples/games/river_sortie/assets/models/tanker_a.glb create mode 100644 examples/games/river_sortie/assets/models/tanker_b.glb create mode 100644 examples/games/river_sortie/lib/main.dart create mode 100644 examples/games/river_sortie/lib/river_sortie.dart create mode 100644 examples/games/river_sortie/lib/src/audio/audio.dart create mode 100644 examples/games/river_sortie/lib/src/audio/audio_scene_component.dart create mode 100644 examples/games/river_sortie/lib/src/audio/sound_emitter_component.dart create mode 100644 examples/games/river_sortie/lib/src/course.dart create mode 100644 examples/games/river_sortie/lib/src/craft.dart create mode 100644 examples/games/river_sortie/lib/src/hud.dart create mode 100644 examples/games/river_sortie/lib/src/levels.dart create mode 100644 examples/games/river_sortie/lib/src/models.dart create mode 100644 examples/games/river_sortie/lib/src/pieces.dart create mode 100644 examples/games/river_sortie/lib/src/river_game.dart create mode 100644 examples/games/river_sortie/lib/src/rules.dart create mode 100644 examples/games/river_sortie/lib/src/sounds.dart create mode 100644 examples/games/river_sortie/lib/src/sprites.dart create mode 100644 examples/games/river_sortie/lib/src/staging.dart create mode 100644 examples/games/river_sortie/pubspec.yaml create mode 100644 examples/games/river_sortie/test/billboards_test.dart create mode 100644 examples/games/river_sortie/test/course_test.dart create mode 100644 examples/games/river_sortie/test/frame_test.dart create mode 100644 examples/games/river_sortie/test/game_test.dart create mode 100644 examples/games/river_sortie/test/levels_test.dart create mode 100644 examples/games/river_sortie/test/release_test.dart create mode 100644 examples/games/river_sortie/test/rules_test.dart create mode 100644 examples/games/river_sortie/test/sound_test.dart create mode 100644 examples/games/river_sortie/test/touch_test.dart create mode 100644 examples/games/river_sortie/web/favicon.png create mode 100644 examples/games/river_sortie/web/icons/Icon-192.png create mode 100644 examples/games/river_sortie/web/icons/Icon-512.png create mode 100644 examples/games/river_sortie/web/icons/Icon-maskable-192.png create mode 100644 examples/games/river_sortie/web/icons/Icon-maskable-512.png create mode 100644 examples/games/river_sortie/web/index.html create mode 100644 examples/games/river_sortie/web/manifest.json diff --git a/examples/games/river_sortie/README.md b/examples/games/river_sortie/README.md new file mode 100644 index 00000000000..48c7c9de0ee --- /dev/null +++ b/examples/games/river_sortie/README.md @@ -0,0 +1,125 @@ +# River Sortie + +A jet up a river that never ends, in 3D. A homage to River Raid, which +Carol Shaw wrote for the Atari 2600 in 1982, and a Flame game from end to +end: Flame runs it, and [`flame_flutter3d`](../../../packages/flame_flutter3d) +draws it in 3D. + +```shell +# WebGL2 +flutter run -d chrome +# WebGPU, or WebGL2 if the browser has none +flutter run -d chrome --dart-define=FLUTTER3D_WEBGPU=true +# Start on a later level +flutter run -d chrome --dart-define=RIVER_LEVEL=3 +# Draw every hitbox round its craft +flutter run -d chrome --dart-define=RIVER_HITBOXES=true +``` + +A web build draws through WebGL2 unless it is built with +`FLUTTER3D_WEBGPU=true`. Then it asks the browser for a WebGPU adapter first +and falls back to WebGL2 when there is none. The flag is off by default +because it puts the WebGPU backend into the bundle, about 1.3 MB more of +`main.dart.js` for this game. + +Only the web platform folder is kept here. `flutter create --platforms=macos .` +adds another; on macOS, iOS and Android the game draws through Flutter GPU, +which has to be switched on for the platform (see the `flame_flutter3d` +example's `pubspec.yaml`). + +Arrows or WASD steer; up and down open and close the throttle. Space fires. +On a phone, Flame's own stick and a fire button do the same. + + +## The game + +The river winds, narrows and splits round islands. Tankers and helicopters +wait on it, jets cut across it, and the tank runs dry unless the jet flies +low over a fuel depot. A bridge ends every stretch and has to be shot down +to pass; lose a jet and the next starts past the last bridge brought down. +A tanker is 30 points, a helicopter 60, a depot 80, a jet 100 and a bridge +500, and every ten thousand points is another jet in reserve. + +The river is the same every run, because a seeded generator lays it out a +stretch at a time (`lib/src/course.dart`), the way the cartridge's river was +the same every time it was switched on. + + +## Levels and tasks + +Five levels, then an open river that goes on for ever +(`lib/src/levels.dart`). Each level is a few bridges long, has its own mix +of targets, speeds and islands, and gives the pilot a task: + +| Level | Bridges | Task | +|-------------|---------|----------------------------------------------------| +| Shakedown | 2 | Bring down both bridges | +| Supply Line | 3 | Sink six tankers | +| Rotor Alley | 3 | Down five helicopters; some fire back | +| Jet Stream | 3 | Shoot down three jets | +| Long Haul | 4 | Eight tankers and four helicopters, on little fuel | + +The last bridge of a level is shielded until its task is done. Glowing +rails show it; a shot throws sparks and the panel says what is still +wanted. Bringing it down pays the level's bonus. + + +## Flame runs it, flutter3d draws it + +`lib/src/river_game.dart` is an ordinary Flame game. Every moving thing is a +Flame component on a flat map of the river; Flame's collision detection +decides what hit what, `onCollisionStart` says so, and Flame paints the +instrument panel. Each of those components is an `Object3dComponent`, which +writes its Flame position into a scene node every frame, and +`Flutter3dFlameWidget` puts the 3D layer under Flame's and runs both from +Flame's clock. + +The one thing not done with hitboxes is the banks: the river's edge is a +curve the course can answer for any point, so the jet asks whether it is +over water. + +`lib/river_sortie.dart` is the game as a library: `RiverScreen` is the game +on a screen of its own, for an app that has other screens, and `RiverApp` +is what `lib/main.dart` runs. + + +## Sound + +This copy is silent. The game still decides what to say and where: an +engine that climbs with the throttle, a shot, a hit, a depot filling the +tank, a low-fuel alarm, a finished level. It says it into +`flutter3d_audio_core`'s silent backend, and the sound test listens to that. +A real backend is `flutter3d_audio`'s SoLoud one, which needs a newer +Flutter than Flame supports, so it stays out of Flame's examples. The +original River Sortie in the [flutter3d repository] plays all of it. + +The two Flame components that connect the sound to the game are in +`lib/src/audio/`. They are published as `flame_flutter3d_audio`, but its +releases are built against Flame 1.x, so the game carries its own copy. + +[flutter3d repository]: https://github.com/pleiondev/flutter3d/tree/v0.8.4/apps/flutter3d_demo_river + + +## Models + +The jets, the helicopter and the tankers are free models, unchanged; who +made each and under what licence is in `assets/models/LICENSES.md`. + +- `jet_player.glb` and `jet_enemy.glb`: "Jet" by Poly by Google, from + [Poly Pizza](https://poly.pizza), under + [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/). +- `helicopter.glb`: "Helicopter" by kazuma, from Poly Pizza, under CC0 1.0. +- `tanker_a.glb`, `tanker_b.glb` and `Textures/colormap.png`: from Kenney's + [Watercraft Kit](https://kenney.nl/assets/watercraft-kit), under CC0 1.0. + +The valley, trees, houses, bridges, depots and effects are built in code +(`lib/src/models.dart`), which also draws primitive stand-ins until the +model files have loaded. + + +## Tests + +The tests cover the course, the rules, the campaign, the sound, a frame +drawn on the CPU backend, and the game itself: the real `FlameGame`, loaded +and stepped the way Flame's own test harness does it, with every hit found +by Flame's collision detection. diff --git a/examples/games/river_sortie/analysis_options.yaml b/examples/games/river_sortie/analysis_options.yaml new file mode 100644 index 00000000000..c378b45f27b --- /dev/null +++ b/examples/games/river_sortie/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:flame_lint/analysis_options_with_dcm.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/examples/games/river_sortie/assets/models/LICENSES.md b/examples/games/river_sortie/assets/models/LICENSES.md new file mode 100644 index 00000000000..b129aceac0a --- /dev/null +++ b/examples/games/river_sortie/assets/models/LICENSES.md @@ -0,0 +1,47 @@ +# Models + +Five models and one texture, as their authors publish them: the files are +not changed. The game fits each to a length in metres and turns it to face +where it is going when it loads; nothing on disk differs from the source. + +Two of the five are **CC BY 3.0**, which asks for credit. The credit is the +entries below, and the README of this game points here. The other three are +CC0, which asks for nothing, so their entries are a note to ourselves. + +Everything else the river is drawn with (the valley, the trees and houses, +the water, the bridges, the fuel depots, the shots and the explosions) is +built in code, in `lib/src/models.dart`. + + +## `jet_player.glb`: the player's jet + +- Title: Jet +- Author: Poly by Google +- Source: Poly Pizza, +- Licence: **CC BY 3.0**, + + +## `jet_enemy.glb`: an enemy jet + +- Title: Jet +- Author: Poly by Google +- Source: Poly Pizza, +- Licence: **CC BY 3.0**, + + +## `helicopter.glb`: a helicopter + +- Title: Helicopter +- Author: kazuma +- Source: Poly Pizza, +- Licence: **CC0 1.0**, + + +## `tanker_a.glb`, `tanker_b.glb` and `Textures/colormap.png`: the tankers + +`ship-cargo-a` and `ship-cargo-b` from the kit, renamed, and the kit's shared +colour map beside them, where their glTF looks for it. + +- Author: Kenney, +- Source: Watercraft Kit 2.1, +- Licence: **CC0 1.0**, diff --git a/examples/games/river_sortie/assets/models/Textures/colormap.png b/examples/games/river_sortie/assets/models/Textures/colormap.png new file mode 100644 index 0000000000000000000000000000000000000000..7db813ffe7e2da2c2c5994e69c9bc2a1afbc1dcc GIT binary patch literal 8814 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 IX5Oj)15nKGZvX%Q literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/assets/models/helicopter.glb b/examples/games/river_sortie/assets/models/helicopter.glb new file mode 100644 index 0000000000000000000000000000000000000000..9aad3f0ebef0aeaf1bda3314a048acf06ca93bff GIT binary patch literal 77712 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 MP1xqp-jjv@1#$EtX8-^I literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/assets/models/tanker_a.glb b/examples/games/river_sortie/assets/models/tanker_a.glb new file mode 100644 index 0000000000000000000000000000000000000000..a6017608b4e9a10727f59a7f2fa348ebfbf7f4eb GIT binary patch literal 98052 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 NH@o(4etY+C{y(P^1@r&_ literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/assets/models/tanker_b.glb b/examples/games/river_sortie/assets/models/tanker_b.glb new file mode 100644 index 0000000000000000000000000000000000000000..fe9739970c03283407c77e3267bd24ec6e5aea18 GIT binary patch literal 79844 zcmeHw2Y^+@_4nL)_Fhp@P**`|%l3loyKBP^DxyTeNLdi2g(4_|t5_i_8q|my3yBT8 z#)9m7sIdfVVj*f`O>9wPY*AzMJHL7NclW&EUDySJe);b{=FT~1&YU{8z1=yC9DPI| zYmE78bzlZ8_hM!KFFl6k+ z5#uI|DH`0pXyPytTGXo~f+h;{w4$-&hBp8z5r(lt#>jZl#N$Ve?=fu1gpuQV3@w^2 z^dZBBHB6j1ZbELx(8(i4G)y>hRKpY}YuLCkK%F?o`?nT4KO&%+L z)Ra|pFPh}&MMv(tceyw)X2?|0R$5$JSyNR~T2@|FUQu3NQ(n`(yturiq_V2KvRcsU zvYL|e?v=%5)fMHHwbdo%B~|6MRTVX2%$QN){V_cXNGK^T>E5Hjoax=C6=|bOVc$v_ zUDCZ&bV$>7h*D5-80@PmYn7@B6`zvsCB@}6WmPrh6_u5>6%{2_rDZh<>I-o?n+lm! z-YS#IVv|aWYbwgCE5)qZ>dF%NDJ!q3D6Ofit}TtDAsML6@l=*o)XGnFRcU2SX+>E{ z?J}W}m9cCVoN;dHlvdO>ubuvT?|JZ^hsUPWl$EO#h|*<*R~8c9ye`Yj zsw*ois;VlgO0lGBD=TX%rMr^cI0mP(KE}*lNt_g7&~&(@kPC=E9LP5k2THD1p2|oJ=6?U{IxYTauR~l zf6|0e!{r$Vd7mWDM|e~#m#bD;^VKDu-EYJQ_bAsqoBEq6D2%ZHLF!% zWl0$x5wwf)U@MfC2D!3Qu5kGR4Flor>(W8bPK5p{JiJDv>4i{IYk#!ZkH!IJ6Ir_0+?q^Zx4 zVeVy8o)d&ReB9)rqZ|6m#Y`9{O_;Zc?v6RL+&kjh`eaG zSUsM)s2>kAu>rViXbVd7!hqY~@@&YxiSuoJTG6oMM~xmnp#ks0rQORUYGWi>E&)m5 z%Q1*W?n{BuLngWJ5sr~Z=8}@Cs@hVyFUV7Osl4*w(YmCjvZ7X=F`M(DW08}Edj`H> zh-JyV((3A(@+$Er5>+OzCuOx|rKRP*q?|_+hm09NTE5f}Kc-0lM)nysT27XtUNTGW zDDtu&6KZQJusWizHgXf9+0O5Y<0elSmivBV*^_Yk=+1%J>wvyy#%~|ZE}efz_`*9U z)&qXM-}w%{b=QuL{_bIy)B^?`@N4rP3&{hRbI1H+M@I(_V9)`dJ>i?EB_8`j_wjDI^S@53al@1iz-g-W1l84j%G9|KOzhclykC{Gh+J$5nJz9a4YArdv2)Kqm~k z_LneYMZMa##UFkcK7P=Y@Pfy0Tuk_)qN(+N+U`xaHb5sl`Lzl4$F2Ke*5?P$)QUIU zI@J7txdxCIog?i#?F0|kEpvf2447+}bmRyyWx_}50!*2tGoA}y>F4}?W{<1u_dKp) zkI0w$n@-=?`FqXMd5#YGJ>Q?d%z42W>T^WrPRB~eQ_D}X;pH;Tzi@hU8F<)D{?(K`hrgNhxAz6!M)etseX`L zL*~i#gml29gCF@K4`I;BQ-~keqC#^)2S4QyCLJ;fYaZBkdTNlHn`9^F(ns=>4qcR^ zc?csWnujpyut)nvJ1HM>l3+bo+dv<+4U~hJ@3q4R>Gs8k){p#UN&R8pE^% zy#Gpir`szX{i#1TIr!9dc8PgT>k!uKx#T&#YdUZ47yXxJDCfYsg$@P}VaPmW=p8Xn zznQ;u@THSG)f29HaF6=R^9DHjh;6ou;p4a4zyAEoiXHtoeLKW(m$&wI>mPdOOxikz z(~Ay@;YSZ|AH(av)~CMuovs7N1yk0Z46f&vRnOq9X5CLL+-4P;nPps$;B3aU3T3eG5kz% zE-#>0U$A9NH@0K_1N(2A1|4&0KlFZl(HV{oU8KWK@oCrxDkerUr)c($wB<} zx}}eh$u)-j>i97J$Rn<+SSMIZnjduft7YmO>ABh$J(s$$ekdPnlJ>wa%13-CA3R(e zm`nNKVH_}5%cMOxN3=}ZgB(#WVo2K%2feQJy45nZZCa+bjrW&J=WG+-M*!#Oi^Fff z>=4m&FzF>-4=cz6dhULt`R@oXopV&g54c%=VL3kkQL#+T->fUgPrdnc`jxYX_g2`z z`!C=eJ;#%yYyQO{WESE9eOdmTJz5v%7MA1l9~H~g{LQ*@{M4IIr(Zey@yv|pC_J;_ zd4@2brAgi+{?MJ zK|crLosn|L<3H1CUD`I#U!3}YvjKF#w3%_xvEp+e-%r6$nD&E5%OPFcsd+%B4YUU` zX%Aq~VGrqqDF<}Q0j%u=o&1ogVcJhRpXXt-h7l9m!}!og>SZ1&lXPtx=F&&-6ZYkk zPJgvb<{CVNsY}ZQJma0`(o3%WoA`2b*bILVetYQC8Pc2K96jgz&CBpGCQ1B|UubT# zUTs4TCVw-Jd|wgE`5+uz)i-i+Ry`2MjvaA?X@su907^Rjvo0pK|mXBTSjvhKa*>$Ugh8 z=hF}DHmkm_&)p8b>eXZO-Z%BTA?}sn|N8ig-1CO~Ih0v<-(&0ZaXf{PY8aQA+D(lO8c64;;&77A>lV5*7pn0H|-&bfG>Xfb{A3ZWl`FZvz zzqCxPSIY!Uzml#Sr1P49*aFr$;xz>EC*9Ar{+>j~s2yn){o@+6Ka)>)ZjR9Ig$#cN50<69B`#eEUiF`L(~~!CcT`Ct%u7 z7&bS{&yNG>S`Oo&>CHAQalX@EUq0#J*Rdi@er+e?Nt-bj_dDEc?t0#L&gyD2}=w^=2~(Z!<4UOHpBULYC3GA9N&J@eSYmPc`%oD0*0NinK0J? zeFU901J-gh59!S|#A`c_Cu5>zf}eB^(;nY8@-WWgOBVS(?v=Mg!b4}Dl@{A#I$_Xx zUT7F|b39AJ=DACpOtHo?=HSTRn4aU$`7XNL`Uel>D|_5I3%d9o!<4UOHpBULYC3GA9N&J@ zeSYmPc`%oD0;c_h8AJLAI&B85n}M%)y)1zcgk{OmBvBbbOZ%{$_d(&e8SUe187^)Sp|}hMYar)y%VL{5#oR z$8GA~3mWHE)fe5hXZ^ro8>InXJ2H&FQ^j|!`a9BgsdF;m!S|o-j~(UUSsU&i!~1lq ztv|A6ZAb6%`<-K$GN}vCet>V8v16WI+JLz`oHyRt1DS-O>zBWr<>;`1{E$x`@N1j( z_qy7C{T;RTh2K%*yW%9e_L1LR<2z^C06S?Le4!1HN!!4$d2~!P59s(#7~gMG4!)zN zF8Hgzm)7w~qEim+p&Zyu-yy$He4tCmP}{6yqHWgkA$*8@->!9WF7n8jLzkvg7uKql zLtXs6z%lYWxOq?RUBBI0?J|J3sogXFz8$}#;qQ9zI~NUWe*HV6#l2=aIYC*k`t5sm zNNbtYrGJkDd+^;pbiMq{Wo|CkD&;)=UFXE#Rq5XW>EHG2_1xJ`FKjqw_!SOzzk^V8 zZJU(s>tr!#Xa4zEp9{P*9v;q9I8UAXSv<<%t(=pMy z7=Q3+U8Li8WSonb=->V6nCn;-iYIK-_PjXekZ@e@{`Kqh*tMQ;uj6*BU;ow5Ka*ZDEOD5~%MLjM>hkCp?NJkNidl_n7Q$J@zd~4e8|9zl`~MN%`X7&^;qS3CM`xwESP3nYL>FHj59X{2 ztaT;8X+?*PjY{7S4tIL>bCE{I%B{Nhq0Xv@%drd#MG8OJt zeruQeKG{Ey4!dCkWF)}IFL}~BRyv+qev%DB z&nDQGrEL$U?{t1ObsL{;2^{4HF33-OezzJ{F+?8YoGRb*;-h#%#gk5=OWd+tV-l;_ zUx{IsYf{HYe0MfPaabWN>p9DHu61d;)+IJ$9U$*{=Pz*3F|J`DHOfCX=f@16Q?}&h z$o9z4}%4%58)i4-a7l&NTa=o<|m1+d9?3I zbg{1+q5$7CtmSJM@p9a5-pa$p15XRzpK@{2dy8i-m{~N~!6gH~&m_?mPdb4oj}C_- zTcvMKOxtC*)X zEayo#@MG<3{v^82g~t=;PScgHjFO*CqAU5};o87l%J=E?H!FGWIXuhh@NfmOQOSu&(^98rNP%c z8w%4~8=p8|k$lk-ol{zVGu*nhbksAwxb{d}uoEnmZtZ;&s~ z$hlZ8ACBV_+3&1d4&2)Kw=SLwmpqiY=k2|+hd;hfxc?Ky*~ZPwvvVs4rsv*zOJ>fs z`(#r;*)V+Kf}-rvKda8(^!P8Z)*5erVr=%FOYhHv?V!UlKR@8aZ0D)Bq_=r?O6Glg zLiVzYuTSrE)r;v)w++pHH2bjh&o=ok{mm|evg@b5s1yFQ;!l%b^rop-Y)jKN=p9pg zaCX4p_AXY0wJwpLQSxJZ#D)xQNYb0+7wGcOt)c%0EU^t4TZwX>}} zw{5b&-K=j`{4hEu9zD*N&_nX;+-dp;m+Y4(8so9Q)2R$Zc#1-0!~EGB2Mo!tu8R7O^e1neXmmi~pSqzE=t(dJMy6(4uwO=)l!@ zPu%;Msaa_m$ZZKMY>nWJd~S5$3ZJ7|&M(*dEUx*OA+d_%kVMz&k^6#tpS0rpk-qO}Sj*S2$aHIM`S%^o<7~#9#$0n_i7;c1 z?}t3teLslrkQj5oIX^|R*!AClWle@ypRaamT+k9&$MC-ab1g|u_?=>iI3&?^428$7 zZR9hFz6$wuN#J}Wq!7&9f!TxObB(rvM}8XJaW!6Zw9J-Vb2N{d%QX$sYJ-_0_^jiQ zMAtFo8i?k)^98V`i>)DOxu2H6aUL6GjD>;S7iP{oWm-7~o1yOvmik?lP3lC%H^CZz3hpe)}#W9Lo9$k1C zXYj-_TR_jti;kxU$1zNZr&}LU%<&%8s%zRKODnR{>Y_eWa1xz7=*a_kMRf3L9>P8y zIPEC}V;Lq<+xK@PEPQZPBg-h)6<`=|$e$0XX+z;UYTey5j zl7;Vh&V}z=slU+yzM^rN+KBnmi5wTZMu)B@yq#yyHp}98&U1e!2K~aHhv)3dS(LNS zJ?}>Zmv85Pj__l|^W!`F(EHL$O5Uuyb?}&i@t$(!{B-)?57qr)(9fdrRr8nm&&9QQ zJ!~7T7mf=qac>^a<;}%2Wb~u>w+a{ej-T>~D}8QU*+YJ3&t+f!B;0Y%*-f8UuAPPc z>7Pvx*S-JKx-T2bnt*>k?8xxW5tlXn<*E(h@sx~z7LDih=KgLA{A&G)9*1+iIXoAq zX8vuj$)q2+?v197AD`&tf4b-M>4T4dys6u(BORXkSHE!Tlr!tD9ki9hPaD%eytwYN zx^K7L)Zwsq%lkiVT6aWQ9rVGU&U4PL`>b;95IDv+mhm7Ohd&RDxU5e6apU+qH^hIT z`@6Tsu(h~MZNz-&%C*&tbN%4GAM$-%U5}i8ALn}Dd>PNn$BM?I{M1Q)oct&sO_C3X zNBOFgd^tSIPm|=w;VPca{?_6METYT!;{4s?-w!p(`Rn9k0HYkAl{^2Na~W4}WQJE*}NPA5`Px9lWKDOa&Fdk9A^l#CWtGkY6_rT=E~| z5ASEf2ZaBuQ*-M@dfil>g}WUv2oBj7OBO-pCVjK^%Ei`C9}! z@F?Fgj&+0eg7X^VSSMH?vQFId0>*K@z;%J+xL)A8K^)^)SHL5Ai?9xtH;#3Sajdgw z{n38lSbwy?;JEVFjYE!-2mc(79`dBR5 zds&1o_cxx6;YIRyyTEgQTiY1=-!JuYw~@SI-6fA!)@UsgoN_PpZ$<~YyZyz$)M zue$Z%>~$vQ*2@~k<9LYPy!fmp{Kviixqg;L`6ABsrf_$EAujyx{+L@=x$NcQ*IIv9 zHU6#D=kv$6XhMb@-`w z7lo;dzODOu`@OS><&mdu75=+sWcs$j`#ZdBKvj6l@sBo5_^#C9eHT=Ov#*`m^wqB_ z9Dd5$Bg0>Ry`=7Mx4e|W{9|7l8h-NRdg&wgeB0so%$*#*Jg7Kx!EGaA{B^9QyA#{?R(o?{L`rpE=()eY5@E4u^juK0Uv#+d~;AAO0!jFKLqexN+p?!zb6v9JS}$ z4&V9xNp-c=FVr30Wk#6uKVF{>UwrLdb>Ci4m+pMYDFw$LlJWmsP?wQ$XOG0PF*+9= zUX+vHB=QT6D}E<`ueJ9{kF6M-exm(khx_B**WM;w`#}5jnZ1^=r=Wbu8&@$nBl6sM zG(RKrBmUg=GqRaFb7*co2#r5(eXJ@jdik^F*ISh1$bi=SBj1N-`8ylPO2Fk@!%sXO z@V_ayfq<>ZCi{Guvm(LHM zqWC%g*EH_ivl93!tsl|1O7j^n#4rcv;@MI>;d(F668Ks6dLQw(UH_x_NQ~V1=TNtn zTZ<=N&*i+xyPmHI7d!I!*ATvH*PpiQU)%LhojWW2eL?QJjaarWo-1*^&%58XR-ezm zs@MN*zd9}3xxQV72md0WJAT9sIN}7~5hsaPj3bUkG9Pip zIB>{A9Dze_BrnDhU+Bd+>=1jP-{H`Ud=Zc4_oh7&|02*SANn{R&5y^$u5cF_SNV4K zY}lOd5cBnT7qO4yLeE70IDNn)e;qFISQy2_;S#TfQM?=uIU=vO#4pC-A93hKJS1Lj z9JtuK2zE#Dgno=?fk*l<-{Fxx;CFbWKOTo3=mQ<|Avaq8@{Hove;BQQHx4_X4{|Xs z@yx;>l@GUGK*zY`!>uPh4t*SlT~Yk0&&iXxP@j`Weef%azZ+k2>uKRVryNlC{ps8F zhQ6DwJ~q7dlmnXfJp147{4F$oiyFVKc95HY;|D>A`PT@3m&hZ2gV665e$X-Q{+DjsjE*I2OsRmJn%yf?1x<7g~nk&$Khv$XCwaJkvxZEJd#&%Jo2Yb z{Bz@x|4rh*!~O9nUh%ltosHs8f3s2i8INVgqxidV@gp0>kNE;W;>UbK9^zMU9Ck~7 z>cmdk3mo~PKY8Qu3-ZMe%@6-W#1Hc%|M1V@kPG=7$2`pEcohFSoe$Vc9DabG`7MAW z{}^}nMEQ>KXgxrl8wW1&i*d18==^c_?^fa4l~rcvO|NK@7Q+7suM^zq^T)T%@$2z0 znooY<7^l3vahabt4n3F;J&K~x(GszFq z*>fKKNy15A-F>eP_}$pRkUi~ZPdaSfP)?EQH@PCkD= zala=&d)m#OboM?y2`AmRFA4YQj0^R$_wA=1_T)?I{rS}6>nENh=f6d#JyHoN?>r+u z&;8BJz>q!Z>`7ly@AFeHdycdB>BLvm`*u*C-;e4P9oXWy|`XBQ{-q_Zdef2H^R z@^Rl!j{mRq^pk!O_j``B|DnfyJA8aa_C5VncemczmzQtp@Rs(R$KIbuI{Tz?(tSSS z?8(>Cp7YrI^GIi(G){Ux-?riUB|{}%&Ha=02Rod-KmKFEH+=nQXD55#Ug8|5-9GNq zIqvtA$9_fQJ|A)FWADpZT{z{l_vMZl(Z|Ivsh@ZG$$8^RxKH=x`QtwB^Aq>^l5n5S z`Rx7i9KN_N7mwyXm!HLT>^Yu|h5a;})o_2FQ z3HRybXYY>_Us2EbNj?2wPk-6_^OE$DpW{C6)2WX=`F(vp-N(uA(}}b9^$@3BNj>E! z^_0V&`q=yPlJt|h5T{*9J>@6$l*69-*!%O6^pKz9 zKJL@0k3IQ)eLmgC$?wyNv-kB7=lrCec5^%l_vz$k?~mv3rKF5DtbiHq; z`+WZ`JV`(4)Z1FzpXbj{8u$5D7Wd~-pWl-&srUJP+@Hrhv+vkzgv+vgzjileeY%K7G0&gP6jm@TlZ6@$W zW^;izHe1MXbF-x!w-EYPGP1QCx0Fvip|z8KE1|cOV>{`$G40KEW?O0P&Gx3F>0ow{ z*3s-JpN?iH`E)cpn_bN=rbybZrjyy->}EQfE^^%6bd_Tl`E(PgyTDxq?jcxrQ!H4q z^gT>ZXtf3e>#$uR&Vw+ z`*-Vw=R5Q(-D)1@tnP#R7JVUVQ z0-q*-C^Ji(rz)gnmf$x<~C_}m^;lq=5BMB zw0q2N<#Ug@*W4%AZ>7J-+%M35<^h54Hw&e|&paso{brHC51J-{7nzLo56YhrWdvR< z$HnF$`7AaM3q5Ndkx$nAPL97bj|y$Ec}(C(&Eo<;W}cA#QFnYou*aqS-aKWVG)tsC zWu7+AnP<&2(w;MaFfW+r%^#(`U|y8ti{>TyEEVV_f&V1XOXg+iUo@`>{IYpf`WMZg z<@jgw7oogrUX#zO=5;x~Zr+gN8|F>k#D!A`-BK}Tsj1v>}3 z2D=1B(sm6x1-l2k1)Zhs9&`!12i=0M(z*vdf|8(TP%Nz^C=Dut@}NvwMNk>k1l2)R zP%Fn8Ireh>9s>0i7{@y4Yl3vJhd{l9FsK)}CfHMs^@8ms)V&4XQ;z#czqio#k+!dl z^pPHTAHfa?_7C=xc0h1oa7b`)aFDb^g1&O>8}yS;-{8=oe{gtkn6&=E5y4Txk--3I zM+F0eV}hfDpGZ3<7!>?8I5rp@94ALW#|1;=I3ySvGz7zgVbU6c5po<6jFivu0v#{? zC^?P_PLR*2;6yo|7>o|a1!IFT(#8ejgNea};Ahe%29ts*!AZenX;Xr!!L;C%;ACmj zf>VRjf*HYdX{QB^!OY-{;B;v-gEND3g0q9Oq@5F-8_W)71?Ned9sE4FFt{K%U)qJi zoZuJ1#lc0=ei6(KE()?jq`rtRxZU}A+76dm3H%VI%+!EX#+!ov_?e^e~;O^kA;7)0G2loW`1@{KOm3Cin zzZ~xm9+1!d!9sx_2p*KrB7w3&CTNnD4HgHF1P=!fNqZ#tUGRADSn#N{$Ac$=Cxa!y z@1;E%JQX|}JQF-E?b+bD;Q8Q>!5^eOAG{DO4PFXfl(sbZlYEv2FU#@e;1xN(61*C` z7W^gnv$WTO*Mql$H-k5%y%oG2yc7H__^Y&cf_H-tg7<^>q|K>alMEci0`BKTa|m%+b+Z-TFbucUnw{98WX1mDW%o8Uk4`6l>I zj^72}2i6AGNV7I&*RgBcwWO_M*R>ni_3e7nHn1DoP3*>YBWau1P3;zTbGw;(HWJ6_rZJJFtGC)-JO ziX2ap<4Ja^9H-io?I}V5^%OhJ&al(%snTZH)9e}cblWKH3_DXkXV^34bA~-jK4;jo z<#@I|$Ii0n*>k1Mva{_4_I&$uX&2ZF?Zx&YJ4f2Z_80b2dx@PZ?NWQ0oo6q%zmzu5 zUSWS_ud-K4`<1=gUT3eh*GRk0&bQaw-`HPEyWZYlZ?-qt8>QWB7s%&kdy5=zvA4?c zR(qR#Znd||@pgNMd~UaQ%JEKnmwfKDciVgIZ|yzO?zQ*Xh4uk^zqEz+L7TBnc9FD< z&Dw|ULw2#WhwUTwG5e_fowUd7<93Pty?sL368ofm#y)MIlJ<;!*8b7{!9FMLkM?={ zl6}#>Anhf))V^Y0wttfLihb3-X8&UUEbTS>x_!&OY2T3cmVMj4WB+FVD(xNnuKmEi zZ{L&lf&IJv*nVU`l=iXxhyB$4(|#iDQ~R0y(tcq-m-eOom;J_mZNHNCjs3U%&i==K zEA2b`y_{A-%1E=RRBD~n+Nrgqt&>_ewLxnA)Oyl3NNt$fB(-sBBWatYHcf4j+B~(H zv@KFwrrM>pPHiQvU22X6z&TF2CmsiM@*shy-1rFKbmPIXG{ zDy?&Bw^Y|um(=dkx~96Nic>vO-K7<$dZx-!rKu8WWvTL1RjM*oA+0J^om%)T_UPiL zW>L2RyE}NpaGx-Szds;r`h6*NK8<<0_XW-HpxNQy-@|th#%w4&F)VYNO0RTu$o%|* z>#{d(`CR9nuG=L$_oK5Ny!f$yHW9w(thw1Yezu;YZ?)SUF}#1jC+bY+y%u3EcnD+e zuirZ~O_@*h&Dl0*e$VXuC*1#MW>ULfm?3=O%d@j@e1?7P#ymCR+8EydskfW%3(j|R z@DRpavDwif^O}!FWwM)G*?Fg7HQ^mkZPf($u?sJY;S&y-nXZ5Rl{Dy!7tgFCEWU&r zb{`&s4j#god&I4?933*dZn{tS^iv0i2R0Oiv*xAb?^7-h2fX=6ddI!abacdU>7X8t z4!HQ@*E8S&e96W?ckqBA&t_s+<|1F<`PW?=hA}*0^Q@L1!!lRP*Ll>o>6~iY;*658J75HnX&zzzgpLfPY!f? z^I|3ZI)+-No(no-=*PB@oiV?TvlrXgW})jCYJYVM^|`5S(|*MY6R;ny;g{LfD@&yC^gxu<8fe8QNk(K2bj@2}RC zsr{+bt78Rz9YZZs&jp<^WSpt1ke%e$arR?Nn?ctx)c)!i>Sr@;oA!$_WXy5@LM|xN zj~nGP=HN#zC?7FrOppt$tB^h5*D{m*((8vd!v>uTt&1^7th8+!)^=)sor@d4?H68_ z{ye?a)bqmu=XVMJSzH~y*f=Tt^D|Q&9nX+oOx(-S0S`R-kvi}IUa;T3X}~Yd`YKHr zbMcG{`og=aS4njllh3j8{TTBe>0I%DX^wve6V*Kzh^ zOPfL0G1UI*80vQuZJYLsF~oZZY{2^n-c2aej~nGP=6tWAe8ikFK`ykeLiT`P%S`f1 zuOHeB8+0zTF2)?O(za<>+o}0=E^z(u_G@Kxv^M&hh{Np4b;I|ox`yaA{8?2Pz9;nh zIb~fmjAZ5Z)H&-`JAC0jCE=niN2Sqg_|g4N4l_c({voZXvpkiNxY2#02qJ4n50w3STyg;Z4F{knYKhd6q-(64{VdHe`{fLUf%w&7VZq zF_f5QqnO{c9D1BDFXl;f=E#qELAtgp4EZB6@rtc`;9-Ge>RruOH#_A+A3>zW__SDLOAgy#|&;FL)k7 zR@?bi`20dFb!@%+NPHf7F;Ak$=a(1rf^@A*!^)l?_5CY;m)dMxmmG}xCGxX|@$Way z*T1ImHSMpjZ%zA4eLK$CBz&OVLFqS6=_}uRG=@8S_=`_er0;EaQ0AOxPjcf+1sDDp z|Do5!p~g$_TIxw9&EbtwHa=FnulNi+6>HZ`nk~Ii^y^E zAP2b2gS@7hYP><@?;nlJ{A@Je)vAIWut(-Qeb5`tkH@3=Zd~FX-Y4P&EoZMD&qniUFK~?0-fT4Ajm!MJargm$M33W#pYT`ga5&v)8_z`Eu*S9~U_scln1N=tI7v`MrTh z>jCof#$gA>Atzd&v_B7r-e^73AGf~fZ!{nNWFvpwdV^lz5)a7B8^?O(cohE(@(lhc z-Z38K1J?&P9>qV#k*8?=x^d(wTEARRE?-!OkdN_b{krkhb^YP>3_5W8T2nakiu0ZE z1m1T2ap$YcciZ*Ht%tVjkGeknFJ6EA=eae7w_ShS^K1Nk)As!1o_FKto9KSjcK!Ju zzy7SL=NFjAg$9`C`~f858(PdaSw1YkA>`7-&I(yRn-XHgI@{`V<_H*3FNoUV-^tx70AoY6Il20nG*@NmmtlNXC zJ*eG-dOfJ#v#}gEmYO`9xXL|SNTr_5q-`Npc~G-wD_5xp^?J6J>OAe_m|ve~JE_sL zgM7A+>-3;v&yG@?XJ@I@v$Lzw(^+ctpiWO`sld}o+U~AmPZv3Mk@`HS(9_M;;z4bm zVyVPaCbf7%<-c+ygf2laSRi)SyX#ZxbBFR8`TM{4n) z9uI2q94NJTP>%<-c=}2$9@OJOEuO=r77yz2pcc=OQi})mculiE6{uY=k; zcS&s>)Yn06oqJtn9n{phPoVpxg3kS}uFm~ZM`xj{tn;9&qq9hA=47N^&cm*P&O_2a zA~kUyb+vO)A7_cw$9X~;>f<~k^>I)m2la9ODD`nrBM0?yP!9(+a!?)T6;~7IW$97> z26b-!;_BbLwr9+BT?b^PSYSL2Vn$ZG$Q|K_LI6zKxY*N^0Az?dsg1#?1y& z;|6tZP~&D3sd0lkH>h#5h19q~og37+X(u&qQ0E3UZrV$Y8`QZ$jhl{A;|6tZP~)a3 z*xA*&L5-WvQsV}7ZcyWHsB?oFH)T@e26b*wH}cUZw~(c0PM?d0*BNGXod&P#{TXs z=u#g5wE}Xm%$2>s4u(wZtBUsWhqBMn!Lrv9dtDtYdl*C6Z|Gp`TOllaFq>qbV{gm_ z4`Ixey^tBols#Y0wrl413}v5}{0`cfU3R}PP582xXNR)i%GvY6h-+i`iKpJKlYL%} z4j#goi+!4q1DUet#qFsn8CH{(eJ*v_FZ0ZWm&Nd`LuO`Vzlp0MQ2)@(Cc-<&o>1B6 zk_A6_2xG47g>iJqlzrUXJCFVQ*{D7M_K$OKdvDpl@92o9?5CERbgn)?Z`l*=V5tv~ zj^Q^S$sk|R-gfNu&cPElNBx0VehkZ8EnnwR+op4>ZHw22tcQ5b$Ql4Wst=HrJ>6kc zAHdNOTdbv6SG2~WefQYgo{LGR-J9WtqCAQ*$uGTrVw=SVoeQlij=982+ooZi3(c={A^W&p9RS(KotFLMX}2f6`{K0hvvzd@ zqz*tR`}Z9kuRZ^rfJ*s z^^n(0TmwOm_VV|ZJ>3q5kL1U-l)8A0jrQG#)GK?>W0-R(llJ@mYF)D5JZFQ775H@w zmuDwq$T(A1Av+-l{5sBlY-uy-I)>U`9YcL@O7e>_WXy5j!M%wx{kTy+V-9}gg7Oh_ z#zg7^ggS;b=)ndFyVKeQP(=v-)Bj5%VZZPT!}Q}gRw$Ug2Y_M2aF-d$j*}w1TfMq{*20VaePqc%3_a7d^n2Y;0c+TBxNeqA6>6B2*CycpT zzP^uZ+w?tM+s0=DJP+`h0nY%S!>`O&1JbgmJ3}AIk7o($;xh(#&i(7$kb3tBX2vk$ zqh->5-(Rgu_M1DsI#%G(?XQlZem2v#X}=gl z#vJ!AE~hn{0ut$s(SO`SC1G zU3|s{|8tvGWT^MBTNcDH=Tau^_x;tnWWTwqOQ2%~ejUT**~u9Cu`OgLSK9-%XPIVhr)#!Pw&6gfjiOQ9fgi_ZQ@X@)2{!1i8>LEMyP(wag^H^!lOA zutDcS>tf6iD{Y&GwVj$@=Rz(Yjq)#gUu}bNJ(ZuvH5C)RA5+8J%L!vN9N89vEI^{c?)!w~=bjh{b z?++U^tn5L);<>dqN>c({+wCtn!6;GIx2fh|)2Sc|1CElvVM^+HkcaV2_3| zSN^Tmb4trpzWe*;+RiUIpAf6)9M3zClIS{jD!-`z5a(CZ)mm*kzY5f)(dU{M^S1L# zu4jmex@N}LwU5iwlL1&!X?q9h&MLOoVX2$m#FXjd5`ktj>El0y`_pjyO z`y^Ie^KJL9T&=3Sb5@|JbfB&l17g!DZtMm5D`}^smd}9Bc zoac?9zaJm_wO3_-KlYVJ^SK`!xUL_t-1-7y59&=||3A*PW$GFD`~9N*aw`dhodU*=0rV{810U>5JkW3D5UTA-zuB*U#FjAg$9`C`~f858(Pda runApp(const RiverApp()); diff --git a/examples/games/river_sortie/lib/river_sortie.dart b/examples/games/river_sortie/lib/river_sortie.dart new file mode 100644 index 00000000000..c93e4682220 --- /dev/null +++ b/examples/games/river_sortie/lib/river_sortie.dart @@ -0,0 +1,98 @@ +/// River Sortie: a jet up a river that never ends, a Flame game drawn in 3D. +/// +/// flutter run -d chrome +/// flutter run -d chrome --dart-define=FLUTTER3D_WEBGPU=true +/// +/// A homage to River Raid, which Carol Shaw wrote for the Atari 2600 in 1982: +/// the river narrows and splits round islands, tankers and helicopters +/// cross it, jets cut over it, the tank runs dry unless the jet flies low +/// over a depot, and a bridge ends every stretch and has to be shot down +/// to pass. Lose a jet and the next starts past the last bridge brought +/// down. The river is the same every run, as it was on the cartridge, +/// because it is laid out by a seeded generator rather than drawn by hand. +/// +/// **Flame runs the game, flutter3d draws it.** `lib/src/river_game.dart` is +/// an ordinary Flame game: components, hitboxes, `onCollisionStart`, a +/// keyboard handler, Flame's own joystick and button on a phone, and a HUD +/// Flame paints. It owns its 3D world through `HasFlutter3d`: the river, the +/// lens, the haze and the camera chasing the jet. Every component that moves +/// is an `Object3dComponent` from `flame_flutter3d`, which writes its Flame +/// position into a scene node each frame; `Flutter3dFlameWidget` puts the 3D +/// layer under Flame's and runs both from Flame's clock. This file hands it +/// the game. +library; + +import 'dart:async'; + +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/material.dart' hide Material; + +import 'package:river_sortie/src/river_game.dart'; + +export 'src/river_game.dart' show RiverGame; + +/// Whether [platform] gets Flame's stick and fire button: a phone or a tablet, +/// which has no keys to fly with. A desktop and a browser keep the keys. +bool hasTouchControls(TargetPlatform platform) => + platform == TargetPlatform.android || platform == TargetPlatform.iOS; + +/// The game as an app of its own, which `main.dart` runs. +class RiverApp extends StatelessWidget { + const RiverApp({super.key}); + + @override + Widget build(BuildContext context) => const MaterialApp( + title: 'River Sortie', + debugShowCheckedModeBanner: false, + home: RiverScreen(), + ); +} + +/// The game on a screen of its own, for an app that has other screens. +class RiverScreen extends StatefulWidget { + const RiverScreen({super.key}); + + @override + State createState() => _RiverScreenState(); +} + +class _RiverScreenState extends State { + /// Starts on the level `--dart-define=RIVER_LEVEL=n` names, counting from + /// one, so a later level can be looked at without flying up to it. + final RiverGame _game = RiverGame(models: true, billboards: true) + ..startOnLevel( + // ignore: do_not_use_environment + const int.fromEnvironment('RIVER_LEVEL', defaultValue: 1) - 1, + ); + + /// A phone or a tablet has no keys, so it gets Flame's stick and trigger. + @override + void initState() { + super.initState(); + if (hasTouchControls(defaultTargetPlatform)) { + _game.addTouchControls(); + } + // Taking off is the player's first key, touch or button, and a browser + // lets a page make a sound only after one. + _game.onFirstFlight = () => unawaited(_game.sound.open()); + // `--dart-define=RIVER_HITBOXES=true` draws every hitbox in the scene, + // round the craft it belongs to. + // ignore: do_not_use_environment + _game.debugHitboxes3d = const bool.fromEnvironment('RIVER_HITBOXES'); + } + + @override + void dispose() { + unawaited(_game.sound.close()); + // The world lives with the game, not the widget: it goes here. + _game.close3d(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFF14161A), + body: Flutter3dFlameWidget(game: _game), + ); +} diff --git a/examples/games/river_sortie/lib/src/audio/audio.dart b/examples/games/river_sortie/lib/src/audio/audio.dart new file mode 100644 index 00000000000..6eb9ebd39ea --- /dev/null +++ b/examples/games/river_sortie/lib/src/audio/audio.dart @@ -0,0 +1,20 @@ +/// Sound for a Flame game bridged to flutter3d. +/// +/// [AudioSceneComponent] is the game's sound: silent until the player's +/// first input opens the speakers, heard from the game's 3D camera, mixed +/// after everything has moved. [SoundEmitterComponent] is a loop that plays +/// while its component lives and asks to be heard. +/// +/// **Kept in the game rather than depended on.** These two components are +/// published as `flame_flutter3d_audio`, whose releases are built against +/// Flame 1.x, so the example carries its own copy of them. It plays into +/// `flutter3d_audio_core`'s silent backend: the game decides what to say and +/// where, and nothing is heard, because a backend would bring SoLoud's native +/// build into Flame's examples. +library; + +import 'package:river_sortie/src/audio/audio.dart' + show AudioSceneComponent, SoundEmitterComponent; + +export 'audio_scene_component.dart'; +export 'sound_emitter_component.dart'; diff --git a/examples/games/river_sortie/lib/src/audio/audio_scene_component.dart b/examples/games/river_sortie/lib/src/audio/audio_scene_component.dart new file mode 100644 index 00000000000..2ea1d9cd687 --- /dev/null +++ b/examples/games/river_sortie/lib/src/audio/audio_scene_component.dart @@ -0,0 +1,222 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/widgets.dart' show AppLifecycleListener, WidgetsBinding; +import 'package:flutter3d_audio_core/flutter3d_audio_core.dart'; +import 'package:river_sortie/src/audio/sound_emitter_component.dart' + show SoundEmitterComponent; + +/// What opening the speakers gives: the scene to play into, and how to +/// close the device under it. Null when there is no sound to be had. +typedef OpenedSpeakers = ({AudioScene scene, Future Function() close}); + +/// A Flame game's sound: one [AudioScene], heard from the game's 3D camera, +/// silent until [open] is called and then through the speakers. +/// +/// **What every bridged game with sound wrote for itself.** River Sortie kept +/// a scene, swapped it for a real one on the first take-off, stopped and +/// restarted its loops across the swap, moved its ears every frame and +/// updated the mix after everything else. This is that, once. +/// +/// **Silent until [open], and [open] belongs to the player's first input.** +/// A browser lets a page make a sound only after the user has touched it, and +/// a game that opened its audio at launch has its first sound refused. Until +/// then everything plays into a [SilentBackend]: the calls a game makes are +/// the same, and nothing is heard. [SoundEmitterComponent]s move themselves +/// onto the real scene when it arrives, so a loop that was already meant to +/// be playing starts playing. +/// +/// **Updated last.** Its priority is high, so the mix is worked out after +/// every emitter and every craft has moved this frame. +class AudioSceneComponent extends Component with UpdatesAtRoot { + AudioSceneComponent({ + required this.bank, + this.maxVoices = 16, + this.opener, + int priority = BridgePriority.audio, + }) : super(priority: priority); + + /// Every sound the game can make, loaded when the speakers open. + final SoundBank bank; + + /// How many voices may sound at once. + final int maxVoices; + + /// How [open] opens the speakers, as a test gives a silent pair it can + /// listen to. Without one there are no speakers to open and the game stays + /// silent: this copy of River Sortie carries no audio backend, so that + /// Flame's examples do not take on SoLoud's native build. A game that wants + /// sound passes `flutter3d_audio`'s `openSpeakers` here. + final Future Function()? opener; + + /// Where the game hears from: [HasFlutter3d.camera3d], when the game has + /// one, facing the way it looks. Otherwise wherever the game puts it. + final AudioListener listener = AudioListener(); + + /// What is played into: silent until [open], then the speakers. + AudioScene get scene => _scene; + AudioScene _scene = AudioScene(backend: SilentBackend()); + + Future Function()? _close; + Future? _opening; + + /// Bumped by [close], so an [open] still waiting on the device when the + /// game closed its sound knows it has been overtaken. + int _generation = 0; + + /// Whether the speakers are open. + bool get isOpen => _close != null; + + /// Opens the speakers and plays through them from the next frame. Call it + /// from the player's first key, touch or button. Twice is once; a device + /// that will not open leaves the game silent, which is a way to play. + /// + /// **A refusal can be asked again.** A browser refuses a page sound before + /// the player has touched it, and an [open] refused once stayed refused + /// for the rest of the game: the next key asks again. A [close] made + /// while the device was still opening wins: the device is closed as soon + /// as it arrives. + Future open() => _opening ??= _open(); + + Future _open() async { + final asked = _generation; + final opened = await (opener ?? _openSpeakers)(); + if (opened == null) { + if (asked == _generation) { + _opening = null; + } + return; + } + if (isRemoved || isRemoving || asked != _generation) { + // Gone, or closed, while the device was opening: nothing will close it + // after this. + await opened.close(); + return; + } + _scene.stopAll(); + _scene = opened.scene; + _close = opened.close; + if (_paused) { + _hush(_scene); + } + } + + bool _paused = false; + double _volume = 1.0; + + /// Whether [pause] has silenced the game. + bool get isPaused => _paused; + + /// Silences every sound where it is, loops included, until [resume]. + /// + /// **For a paused game.** Flame stops updating a paused game, this with + /// it, and whatever was sounding went on sounding at its last loudness: + /// an engine droning under the pause menu. A game that pauses its engine + /// calls this; a game sent to the background is paused here by itself. + void pause() { + if (_paused) { + return; + } + _paused = true; + _volume = _scene.mixer.volumeOf(AudioBus.master); + _hush(_scene); + } + + /// Brings back what [pause] silenced, at the volume it had. + void resume() { + if (!_paused) { + return; + } + _paused = false; + _scene.mixer.setVolume(AudioBus.master, _volume); + _scene.update(listener); + } + + /// Turns [scene] down to nothing and applies it at once: no update runs + /// while the game is paused to apply it later. + void _hush(AudioScene scene) { + scene.mixer.setVolume(AudioBus.master, 0.0); + scene.update(listener); + } + + AppLifecycleListener? _lifecycle; + bool _pausedByLifecycle = false; + + @override + void onMount() { + super.onMount(); + _lifecycle = _listen(); + } + + /// Null where there is no app to leave: a game stepped in a plain Dart + /// test, with no widgets binding. + AppLifecycleListener? _listen() { + final WidgetsBinding binding; + try { + binding = WidgetsBinding.instance; + } on Object { + return null; + } + return AppLifecycleListener( + binding: binding, + onHide: () { + if (_paused) { + return; + } + _pausedByLifecycle = true; + pause(); + }, + onShow: () { + if (!_pausedByLifecycle) { + return; + } + _pausedByLifecycle = false; + resume(); + }, + ); + } + + Future _openSpeakers() async => null; + + /// Plays [sound] once, at [at] in the scene or at the listener. + SoundEmitter play(SoundDef sound, {Vector3? at}) => + _scene.play(sound, at ?? listener.position); + + /// Closes the speakers, if they are open. The game goes on, silent. + Future close() async { + final closing = _close; + _generation++; + _close = null; + _opening = null; + _scene.stopAll(); + _scene = AudioScene(backend: SilentBackend()); + await closing?.call(); + } + + /// The listener onto the camera and the mix worked out, from the game's + /// root wherever this was added: inside the world it ran before Flame's + /// camera, and was heard from where the camera had been a frame before. + @override + void rootUpdate(double dt) { + final game = findGame(); + if (game is HasFlutter3d && game.has3d) { + final camera = game.camera3d; + // The camera's turn in the world, not against its parent: a camera + // riding a craft looked the craft's way plus its own, and was heard + // looking only its own. + final world = camera.worldMatrix; + listener.position.setFrom(world.getTranslation()); + final forward = world.transformed3(Vector3(0.0, 0.0, -1.0)) + ..sub(listener.position); + listener.aimAlong(listener.position, forward); + } + _scene.update(listener); + } + + @override + void onRemove() { + _lifecycle?.dispose(); + _lifecycle = null; + close(); + super.onRemove(); + } +} diff --git a/examples/games/river_sortie/lib/src/audio/sound_emitter_component.dart b/examples/games/river_sortie/lib/src/audio/sound_emitter_component.dart new file mode 100644 index 00000000000..d144a5f0fa5 --- /dev/null +++ b/examples/games/river_sortie/lib/src/audio/sound_emitter_component.dart @@ -0,0 +1,128 @@ +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter3d_audio_core/flutter3d_audio_core.dart'; + +import 'package:river_sortie/src/audio/audio_scene_component.dart'; + +/// A looping sound that plays while this component is in the game and +/// [playing] is true: an engine, a siren, a refuelling tone. +/// +/// **Held open by state, not started and stopped by events.** A loop a game +/// starts on one event and stops on another is a loop left running when the +/// second event never comes: the craft was removed, the level restarted, the +/// speakers opened in between. This one asks, every frame, whether it should +/// be sounding, and makes it so: it starts when [playing] turns on, stops +/// when it turns off or the component is removed, and moves onto the real +/// scene when the game's [AudioSceneComponent] opens the speakers. +/// +/// **Where its parent is.** Under a bridged component it sounds from that +/// component's scene position; anywhere else, from the listener. [gain] and +/// [rate] are read every frame, so an engine can climb with the throttle. +class SoundEmitterComponent extends Component { + SoundEmitterComponent( + this.sound, { + this.playing = true, + this.gain = 1.0, + this.rate = 1.0, + super.priority, + }); + + /// What it plays. A loop, normally; a one-shot plays once each time + /// [playing] turns on. + final SoundDef sound; + + /// Whether it should be sounding. + bool playing; + + /// Scales the sound's own gain. + double gain; + + /// Scales the sound's own speed, pitch with it. + double rate; + + AudioSceneComponent? _audio; + SoundEmitter? _emitter; + AudioScene? _playingOn; + + /// Whether a one-shot has played for this turn of [playing]: moved onto + /// the speakers when they open, it would otherwise play a second time. + bool _shot = false; + + /// Seconds until the game is searched again for its sound. + double _lookAgainIn = 0.0; + + /// The voice this is holding, while it holds one. + SoundEmitter? get emitter => _emitter; + + /// **Found again when it goes.** A level restarted with a new + /// [AudioSceneComponent] left every emitter playing into the old one, + /// closed and no longer updated, and the new level was silent. And a game + /// with no sound is searched a few times a second, not every frame by + /// every emitter. + AudioSceneComponent? _find(double dt) { + final had = _audio; + if (had != null && had.isMounted) { + return had; + } + if (had != null) { + _stop(); + _audio = null; + } + _lookAgainIn -= dt; + if (_lookAgainIn > 0.0) { + return null; + } + _lookAgainIn = 0.25; + // Looked for until found rather than once on mount: added in the same + // batch as the game's AudioSceneComponent, this can mount first and + // find nothing there yet. + return _audio = findGame() + ?.descendants() + .whereType() + .firstOrNull; + } + + @override + void update(double dt) { + super.update(dt); + final audio = _find(dt); + if (audio == null) { + return; + } + if (!playing) { + _stop(); + _shot = false; + return; + } + final scene = audio.scene; + final at = switch (parent) { + final Object3dComponent owner => owner.scenePosition, + _ => audio.listener.position, + }; + if (_emitter == null || !identical(_playingOn, scene)) { + _stop(); + if (!sound.loop && _shot) { + return; + } + _emitter = scene.play(sound, at); + _playingOn = scene; + _shot = true; + } + _emitter! + ..position.setFrom(at) + ..gain = gain + ..rate = rate; + } + + void _stop() { + _emitter?.stop(); + _emitter = null; + _playingOn = null; + } + + @override + void onRemove() { + _stop(); + super.onRemove(); + } +} diff --git a/examples/games/river_sortie/lib/src/course.dart b/examples/games/river_sortie/lib/src/course.dart new file mode 100644 index 00000000000..5f454b41d5b --- /dev/null +++ b/examples/games/river_sortie/lib/src/course.dart @@ -0,0 +1,435 @@ +/// The river: where the water is at every metre of the flight, and what +/// waits on it. +/// +/// Plain Dart. Nothing here knows about Flame or a renderer, so the whole +/// course can be laid out and checked in a unit test, and the game and the +/// terrain mesh read one description of it rather than two. +/// +/// **Distance runs up the river.** A point on the course is `(x, distance)`: +/// `x` across, metres from the middle of the valley, and `distance` along, +/// metres from where the first flight starts. Flame's `y` is `-distance`, +/// because Flame's `y` grows down the screen and the jet flies up it. +library; + +import 'dart:math' as math; + +import 'package:flutter3d_sim/flutter3d_sim.dart' show GameRandom; + +import 'package:river_sortie/src/levels.dart'; + +/// How long one stretch of river is, bridge to bridge. +const double sectionLength = 180.0; + +/// How far either side of the valley's middle the water may ever reach. +const double riverReach = 17.0; + +/// How far either side of the middle the trees and houses stand. +const double valleyReach = 46.0; + +/// How far either side the grass is drawn: as far as the camera sees at +/// all. The stretches ahead reach a few hundred units up the river, and a +/// wide window sees about as far across up there, so land that stopped at +/// [valleyReach] left the sky showing in both top corners. Only the outer +/// quads of each row get wider; the valley has no more vertices for it. +const double landReach = 400.0; + +/// Half the water's width under a bridge, and where each stretch starts. +const double narrowHalf = 4.5; + +/// How far before the end of its stretch a bridge stands. +const double bridgeInset = 10.0; + +/// The river every run flies, unless a test asks for another. +const int defaultSeed = 1982; + +/// How high the land stands above the water, and how deep the bed lies +/// under it, out of sight. +const double landHeight = 0.9; +const double bedDepth = -0.4; + +/// A bank's slope: how far it reaches onto the land from the water line +/// at the top, and out under the water at the foot. +const double bankTop = 0.55; +const double bankUnder = 0.2; + +/// How wide an island is before it stands at full height. A narrower one +/// is lower, down to nothing on the bed. +const double islandRise = 0.8; + +/// What can be shot, and what it is worth. +enum TargetKind { + tanker(30, 1.7), + helicopter(60, 1.3), + depot(80, 0.9), + jet(100, 1.1); + + const TargetKind(this.points, this.halfLength); + + final int points; + + /// Half its length across the river, the way it moves: what keeps it off + /// the banks. + final double halfLength; +} + +/// One target, as the course lays it out: before it is a component. +final class TargetPlan { + const TargetPlan({ + required this.kind, + required this.distance, + required this.x, + required this.heading, + required this.speed, + this.gunner = false, + }); + + final TargetKind kind; + final double distance; + final double x; + + /// `1` moving towards `+x`, `-1` towards `-x`. A still target still faces + /// one way. + final int heading; + + /// Metres per second once it wakes; zero for one that never moves. + final double speed; + + /// A helicopter that fires at the jet. + final bool gunner; +} + +enum SceneryKind { tree, pine, house } + +/// A tree or a house on the land, baked into the terrain mesh. +final class SceneryPlan { + const SceneryPlan({ + required this.kind, + required this.distance, + required this.x, + required this.scale, + required this.turn, + }); + + final SceneryKind kind; + final double distance; + final double x; + final double scale; + + /// Radians about the vertical, so no two houses face the same way. + final double turn; +} + +/// The river across one row of the course. +/// +/// **An island is a width, not a flag.** Between two rows with and without +/// one, [island] grows from zero, so an island rises out of the middle of +/// the stream as a point and widens: a crossing into it is never a wall. +/// +/// **The water is where the picture shows water.** A narrow island is also +/// a low one, under the surface until it is about a third of a metre +/// across, so what the jet can hit is [dryIsland], the part standing out +/// of the water, worked out from the same slopes the terrain mesh is built +/// with. Testing against [island] itself crashed a jet flying straight up +/// the middle into an island a few microns wide and still on the bed. +final class RiverRow { + const RiverRow({ + required this.center, + required this.half, + required this.island, + }); + + final double center; + final double half; + final double island; + + double get left => center - half; + double get right => center + half; + double get islandLeft => center - island; + double get islandRight => center + island; + + /// How far the island's slopes reach from its line, 0 to 1: it stands at + /// full height only once it is [islandRise] across. + double get islandGrown => (island / islandRise).clamp(0.0, 1.0); + + /// Half the width of the island above the water, zero while it is under. + double get dryIsland { + final grown = islandGrown; + final crest = bedDepth + (landHeight - bedDepth) * grown; + if (crest <= 0.0) { + return 0.0; + } + final foot = island + bankUnder * grown; + final shoulder = island - bankTop * grown; + return foot + (shoulder - foot) * -bedDepth / (crest - bedDepth); + } + + bool get hasIsland => dryIsland > 0.0; + + /// The stretches of open water across this row, left to right. + List<(double, double)> get channels { + final dry = dryIsland; + return dry > 0.0 + ? <(double, double)>[(left, center - dry), (center + dry, right)] + : <(double, double)>[(left, right)]; + } + + /// The channel [x] is over, or null over land. + (double, double)? channelAt(double x) { + for (final channel in channels) { + if (x >= channel.$1 && x <= channel.$2) { + return channel; + } + } + return null; + } + + /// Whether everything within [halfWidth] of [x] is over water. + bool isWater(double x, {double halfWidth = 0.0}) => channels.any( + (channel) => x - halfWidth >= channel.$1 && x + halfWidth <= channel.$2, + ); + + /// Whether [x] is on land at least [margin] from any water. + bool isLand(double x, {double margin = 0.0}) { + final outside = x < left - margin || x > right + margin; + final dry = dryIsland; + final onIsland = x > center - dry + margin && x < center + dry - margin; + return outside || onIsland; + } +} + +/// The shape of the river at one point along a section, before smoothing. +typedef _Key = ({double at, double center, double half, double island}); + +/// One stretch of river, bridge to bridge, and what is on it. +/// +/// **Every stretch starts and ends narrow, on the middle line.** That is what +/// makes a stretch generated on its own join the one before it without +/// either knowing the other: both meet at [narrowHalf] around zero, which +/// is also where the bridge crosses. +final class Section { + Section._(this.index, this._keys, this.targets, this.scenery); + + /// Section [index] of the river [seed] lays out. The same two numbers give + /// the same stretch, every time: the river is a place, not a dice roll. + /// + /// What is on it, and how wide and winding it runs, is the [Mix] of the + /// level it belongs to, by [stageOf]. + factory Section.generate(int index, {int seed = defaultSeed}) { + final random = GameRandom(_seedFor(seed, index)); + final mix = stageOf(index).level.mix; + final keys = _layOut(index, mix, random); + final shell = Section._(index, keys, const [], const []); + return Section._( + index, + keys, + index < 0 ? const [] : _populate(shell, mix, random), + _plant(shell, random), + ); + } + + final int index; + final List<_Key> _keys; + final List targets; + final List scenery; + + double get start => index * sectionLength; + double get end => start + sectionLength; + double get bridgeAt => end - bridgeInset; + + /// The stretch before the first flight has no bridge to cross: the jet + /// starts past it. + bool get hasBridge => index >= 0; + + /// The river at [distance], which has to fall inside this section. + RiverRow rowAt(double distance) { + final local = (distance - start).clamp(0.0, sectionLength); + var i = 0; + while (i < _keys.length - 2 && _keys[i + 1].at <= local) { + i++; + } + final a = _keys[i]; + final b = _keys[i + 1]; + final t = ((local - a.at) / (b.at - a.at)).clamp(0.0, 1.0); + final s = t * t * (3.0 - 2.0 * t); + double mix(double from, double to) => from + (to - from) * s; + return RiverRow( + center: mix(a.center, b.center), + half: mix(a.half, b.half), + island: mix(a.island, b.island), + ); + } + + /// Narrow at both ends, and every sixteen metres between a width, a + /// centre and maybe an island. The stretch before the first one is a calm + /// straight run, so the first thing a player sees is not a test. + static List<_Key> _layOut(int index, Mix mix, GameRandom random) { + _Key narrow(double at) => + (at: at, center: 0.0, half: narrowHalf, island: 0.0); + if (index < 0) { + return <_Key>[ + narrow(0.0), + (at: 20.0, center: 0.0, half: 9.0, island: 0.0), + (at: sectionLength - 30.0, center: 0.0, half: 9.0, island: 0.0), + narrow(sectionLength - 20.0), + narrow(sectionLength), + ]; + } + final keys = <_Key>[narrow(0.0), narrow(14.0)]; + var center = 0.0; + for (var at = 30.0; at <= sectionLength - 38.0; at += 16.0) { + final half = + mix.narrowest + (mix.widest - mix.narrowest) * random.nextDouble(); + final reach = riverReach - half; + center = (center + (random.nextDouble() * 2.0 - 1.0) * 8.0).clamp( + -reach, + reach, + ); + // No island on the first width after the start: one growing out of + // the middle there rose right in front of a jet that had only just + // taken off straight up the stream. + final island = + at > 30.0 && half >= 10.0 && random.nextDouble() < mix.islands + ? math.min(half * (0.3 + 0.2 * random.nextDouble()), half - 3.5) + : 0.0; + keys.add((at: at, center: center, half: half, island: island)); + } + return keys + ..add(narrow(sectionLength - 22.0)) + ..add(narrow(sectionLength)); + } + + /// Targets from a little past the start to a little short of the bridge, + /// in the level's [Mix]: its kinds, its speeds, its spacing. + static List _populate( + Section section, + Mix mix, + GameRandom random, + ) { + final targets = []; + final (closest, furthest) = mix.spacing; + var distance = section.start + 28.0; + while (distance < section.end - 32.0) { + final kind = mix.kindFor(random.nextDouble()); + final heading = random.nextBool() ? 1 : -1; + final rolledMoving = random.nextDouble() < mix.moving; + final gunner = + kind == TargetKind.helicopter && random.nextDouble() < mix.gunners; + final row = section.rowAt(distance); + final x = switch (kind) { + // A jet comes in from off the side and crosses the whole valley. + TargetKind.jet => -heading * (riverReach + 6.0), + _ => _placeOnWater(row, kind.halfLength, random), + }; + if (x != null) { + // **A mover needs water to move across.** One put on a channel + // barely longer than itself turned at each bank several times a + // second and read as a craft shaking in place, not moving. + final room = switch (row.channelAt(x)) { + (final from, final to) => to - from - 2.0 * kind.halfLength, + null => 0.0, + }; + final moves = rolledMoving && room >= _roomToMove; + targets.add( + TargetPlan( + kind: kind, + distance: distance, + x: x, + heading: heading, + speed: + mix.speed * + switch (kind) { + TargetKind.depot => 0.0, + TargetKind.jet => 14.0, + TargetKind.helicopter => moves ? 4.5 : 0.0, + TargetKind.tanker => moves ? 3.0 : 0.0, + }, + gunner: gunner, + ), + ); + } + distance += closest + (furthest - closest) * random.nextDouble(); + } + return targets; + } + + /// How far a tanker or a helicopter must be able to travel across its + /// channel to be given a speed at all. + static const double _roomToMove = 3.0; + + /// Somewhere on one of the row's channels, clear of both banks, or null + /// when the channel picked is too narrow for it. + static double? _placeOnWater( + RiverRow row, + double halfLength, + GameRandom random, + ) { + final channels = row.channels; + final (from, to) = channels[random.nextInt(channels.length)]; + final lo = from + halfLength + 0.4; + final hi = to - halfLength - 0.4; + if (hi <= lo) { + return null; + } + return lo + (hi - lo) * random.nextDouble(); + } + + /// Trees and the odd house on the land either side and on the islands, + /// kept off the banks and off the road that runs to the bridge. + static List _plant(Section section, GameRandom random) { + final scenery = []; + for (var at = 1.5; at < sectionLength; at += 2.5) { + final distance = section.start + at; + final row = section.rowAt(distance); + final count = 3 + random.nextInt(3); + for (var i = 0; i < count; i++) { + final x = (random.nextDouble() * 2.0 - 1.0) * (valleyReach - 1.0); + final roll = random.nextDouble(); + final keepOff = (distance - section.bridgeAt).abs() < 3.5; + if (!keepOff && row.isLand(x, margin: 1.6)) { + scenery.add( + SceneryPlan( + kind: roll < 0.07 + ? SceneryKind.house + : roll < 0.55 + ? SceneryKind.pine + : SceneryKind.tree, + distance: distance + random.nextDouble() * 1.5, + x: x, + scale: 0.8 + 0.5 * random.nextDouble(), + turn: random.nextDouble() * math.pi * 2.0, + ), + ); + } + } + } + return scenery; + } + + /// One seed per section from the run's seed and the section's number. + /// + /// **Mixed, not added.** [GameRandom] is a xorshift, and two seeds a few + /// units apart start it on sequences that agree for their first draws, so + /// neighbouring sections came out with the same first width. + static int _seedFor(int seed, int index) { + var h = (seed * 0x9E3779B1 + index * 0x85EBCA77) & 0xFFFFFFFF; + h = ((h ^ (h >> 16)) * 0x7FEB352D) & 0xFFFFFFFF; + h = ((h ^ (h >> 15)) * 0x846CA68B) & 0xFFFFFFFF; + return (h ^ (h >> 16)) | 1; + } +} + +/// The whole river, generated a section at a time as the jet reaches it. +final class Course { + Course({this.seed = defaultSeed}); + + final int seed; + final Map _sections = {}; + + Section section(int index) => + _sections.putIfAbsent(index, () => Section.generate(index, seed: seed)); + + int sectionIndexAt(double distance) => (distance / sectionLength).floor(); + + RiverRow rowAt(double distance) => + section(sectionIndexAt(distance)).rowAt(distance); +} diff --git a/examples/games/river_sortie/lib/src/craft.dart b/examples/games/river_sortie/lib/src/craft.dart new file mode 100644 index 00000000000..52e1bcfb20d --- /dev/null +++ b/examples/games/river_sortie/lib/src/craft.dart @@ -0,0 +1,142 @@ +part of 'river_game.dart'; + +/// Which model plays which part. +enum Craft { + player('assets/models/jet_player.glb', 2.3, floats: false), + enemyJet('assets/models/jet_enemy.glb', 2.5, floats: false), + helicopter('assets/models/helicopter.glb', 2.8, floats: false), + tankerA('assets/models/tanker_a.glb', 3.7, floats: true), + tankerB('assets/models/tanker_b.glb', 3.7, floats: true); + + const Craft(this.file, this.length, {required this.floats}); + + final String file; + + /// Nose to tail once fitted, in metres: about the length of the hitbox + /// it stands for, so what is drawn is what can be hit. + final double length; + + /// Sits on the water rather than centred on its flying height. + final bool floats; + + /// How it is worn: a ship stands on the water with its keel 15 cm under + /// it, a flying craft is centred on its height. + ModelLook get look => ModelLook( + file, + length: length, + onGround: floats, + offset: floats ? Vector3(0.0, -0.15, 0.0) : null, + ); + + static Map get looks => { + for (final craft in values) craft: craft.look, + }; +} + +/// The meshes and materials shared by everything of a kind, uploaded once. +/// +/// **Every stand-in faces +Z, the way every model does.** The models this +/// game loads all happen to be built nose along +Z, so the primitives are +/// turned to match when they are made, and one rule turns either to face +/// where it is going: [TargetComponent.face], [JetComponent.bankTowards]. +final class _Kit { + _Kit(this.device) + : playerJet = _upload( + device, + jetMesh(Vector4(0.9, 0.2, 0.15, 1.0), Vector4(0.95, 0.95, 0.9, 1.0)), + math.pi, + ), + enemyJet = _upload( + device, + jetMesh(Vector4(0.25, 0.3, 0.55, 1.0), Vector4(0.6, 0.65, 0.75, 1.0)), + math.pi, + ), + tanker = _upload(device, tankerMesh(), -math.pi / 2.0), + helicopter = _upload(device, helicopterMesh(), -math.pi / 2.0), + rotor = DeviceMesh.upload(device, rotorMesh()), + depot = DeviceMesh.upload(device, depotMesh()), + shot = DeviceMesh.upload(device, shotMesh()), + bullet = DeviceMesh.upload(device, bulletMesh()), + shard = DeviceMesh.upload(device, shardMesh()), + puff = DeviceMesh.upload(device, puffMesh()), + water = DeviceMesh.upload(device, waterMesh()); + + final GraphicsDevice device; + final DeviceMesh playerJet; + final DeviceMesh enemyJet; + final DeviceMesh tanker; + final DeviceMesh helicopter; + final DeviceMesh rotor; + final DeviceMesh depot; + final DeviceMesh shot; + final DeviceMesh bullet; + final DeviceMesh shard; + final DeviceMesh puff; + final DeviceMesh water; + + /// White, so the vertex colours are the colours. + final engine.Material painted = engine.Material( + name: 'painted', + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + roughness: 0.8, + ); + + final engine.Material waterMaterial = engine.Material( + name: 'water', + baseColor: Vector4(0.02, 0.09, 0.26, 1.0), + roughness: 0.15, + ); + + /// A shot: lit from inside, so it reads against the water and the land. + final engine.Material glow = engine.Material( + name: 'glow', + baseColor: Vector4(1.0, 0.85, 0.4, 1.0), + emissive: Vector3(1.0, 0.75, 0.3), + emissiveStrength: 4.0, + ); + + /// A helicopter's bullet: red, so it reads as the enemy's and not a + /// shot of the jet's own. + final engine.Material tracer = engine.Material( + name: 'tracer', + baseColor: Vector4(1.0, 0.2, 0.15, 1.0), + emissive: Vector3(1.0, 0.15, 0.1), + emissiveStrength: 6.0, + ); + + /// A bridge's shield, while the level's task is not done. + final engine.Material shield = engine.Material( + name: 'shield', + baseColor: Vector4(0.3, 0.95, 1.0, 1.0), + emissive: Vector3(0.2, 0.9, 1.0), + emissiveStrength: 5.0, + ); + + static DeviceMesh _upload(GraphicsDevice device, MeshData mesh, double yaw) => + DeviceMesh.upload(device, mesh.transformed(Matrix4.rotationY(yaw))); +} + +/// The models, put on the bodies the game already moves. +/// +/// **What is drawn hangs from the component's `visual` node.** Until +/// [dressWithModels] has loaded the files, and always in the tests, which +/// never load them, that is the primitive from `models.dart`; the game's +/// `ModelWardrobe` puts each model on every visual node that plays its part +/// as it arrives, including those made while it was loading. The visual +/// node is also what turns a craft to face its way and banks the jet, +/// because the bridge leaves its rotation alone. +extension RiverGameCraft on RiverGame { + /// Loads every model and dresses whatever is already in play. A target + /// made later is dressed as it is made. A model that fails to load + /// leaves its primitive, and the game plays on. + /// + /// [source] is the app bundle in the app; a test, which has no bundle an + /// isolate can read, hands in the files on disk. + Future dressWithModels({ + AssetSource Function(String path) source = BundleAssetSource.new, + }) => wardrobe.load( + source: source, + onError: (craft, error) => + debugPrint('river: ${craft.file} did not load ($error)'), + ); +} diff --git a/examples/games/river_sortie/lib/src/hud.dart b/examples/games/river_sortie/lib/src/hud.dart new file mode 100644 index 00000000000..dc45250da2f --- /dev/null +++ b/examples/games/river_sortie/lib/src/hud.dart @@ -0,0 +1,227 @@ +part of 'river_game.dart'; + +/// The instrument panel along the bottom: the score, the fuel gauge, the +/// jets in reserve, and a line across the middle when there is something +/// to say. +/// +/// **The one thing Flame draws.** Everything above the panel is the 3D +/// layer showing through the transparent game; this component sits in +/// Flame's own viewport and paints with Flame's own canvas. +final class RiverHud extends PositionComponent with HasGameRef { + static const double panelHeight = 78.0; + static const String _keysHelp = + 'Space to fly and fire, arrows or WASD to steer'; + static const double _gaugeWidth = 200.0; + static const double _gaugeHeight = 22.0; + + static const List _shadow = [ + Shadow(blurRadius: 4.0, color: Color(0xAA000000)), + ]; + + final TextPaint _score = TextPaint( + style: const TextStyle( + color: Color(0xFFF4D35E), + fontSize: 26.0, + fontWeight: FontWeight.w700, + letterSpacing: 3.0, + ), + ); + + final TextPaint _label = TextPaint( + style: const TextStyle( + color: Color(0xFFE8E8E8), + fontSize: 13.0, + fontWeight: FontWeight.w700, + ), + ); + + final TextPaint _banner = TextPaint( + style: const TextStyle( + color: Color(0xFFFFFFFF), + fontSize: 22.0, + fontWeight: FontWeight.w700, + letterSpacing: 2.0, + shadows: _shadow, + ), + ); + + final TextPaint _detail = TextPaint( + style: const TextStyle( + color: Color(0xFFF0F0F0), + fontSize: 15.0, + height: 1.4, + shadows: _shadow, + ), + ); + + /// A line of the task that is done. + final TextPaint _done = TextPaint( + style: const TextStyle( + color: Color(0xFF7CE38B), + fontSize: 13.0, + fontWeight: FontWeight.w700, + shadows: _shadow, + ), + ); + + final Paint _panel = Paint()..color = const Color(0xE0202428); + final Paint _rule = Paint()..color = const Color(0xFF8A8F94); + final Paint _dial = Paint()..color = const Color(0xFF101214); + final Paint _frame = Paint() + ..color = const Color(0xFFE8E8E8) + ..style = PaintingStyle.stroke + ..strokeWidth = 2.0; + final Paint _needle = Paint(); + final Paint _jet = Paint()..color = const Color(0xFFF4D35E); + + double _clock = 0.0; + + /// On for a quarter of a second, off for the next: the rate the low-fuel + /// warning and the needle blink at. + bool get _blink => (_clock * 4.0).floor().isEven; + + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + this.size = size; + } + + @override + void update(double dt) { + super.update(dt); + _clock += dt; + } + + @override + void render(Canvas canvas) { + if (!gameRef.built) { + return; + } + final width = size.x; + final top = size.y - panelHeight; + final run = gameRef.run; + + canvas + ..drawRect(Rect.fromLTWH(0.0, top, width, panelHeight), _panel) + ..drawRect(Rect.fromLTWH(0.0, top, width, 3.0), _rule); + + _score.render( + canvas, + '${run.score}', + Vector2(width / 2.0, top + 8.0), + anchor: Anchor.topCenter, + ); + + // The gauge: E, a half and F, and a needle that goes red and blinks + // below a quarter of a tank. + final left = width / 2.0 - _gaugeWidth / 2.0; + final gaugeTop = top + 44.0; + final dial = Rect.fromLTWH(left, gaugeTop, _gaugeWidth, _gaugeHeight); + canvas + ..drawRect(dial, _dial) + ..drawRect(dial, _frame); + for (final (mark, at) in <(String, double)>[ + ('E', 0.07), + ('½', 0.5), + ('F', 0.93), + ]) { + _label.render( + canvas, + mark, + Vector2(left + _gaugeWidth * at, gaugeTop + _gaugeHeight / 2.0), + anchor: Anchor.center, + ); + } + final low = run.fuelLow; + _needle.color = low ? const Color(0xFFFF4B3E) : const Color(0xFFF4D35E); + if (!low || _blink) { + final x = left + 10.0 + (_gaugeWidth - 20.0) * run.fuel; + canvas.drawRect( + Rect.fromLTWH(x - 2.0, gaugeTop - 4.0, 4.0, _gaugeHeight + 8.0), + _needle, + ); + } + + // A small jet for each one in reserve, to the left of the gauge. + for (var i = 0; i < math.min(run.reserve, 6); i++) { + final cx = left - 22.0 - i * 20.0; + final cy = gaugeTop + _gaugeHeight / 2.0; + canvas.drawPath( + Path() + ..moveTo(cx, cy - 8.0) + ..lineTo(cx + 7.0, cy + 6.0) + ..lineTo(cx, cy + 3.0) + ..lineTo(cx - 7.0, cy + 6.0) + ..close(), + _jet, + ); + } + + // Which bridge of the level this is, and a word while a depot fills the + // tank. + final stage = gameRef.stage; + final section = gameRef.course.sectionIndexAt(gameRef.distance); + final bridges = stage.index == campaign.length - 1 + ? 'BRIDGE ${section - stage.first + 1}' + : 'BRIDGE ${section - stage.first + 1} / ${stage.level.bridges}'; + _label.render( + canvas, + gameRef.refuelling ? 'REFUELLING' : bridges, + Vector2(left + _gaugeWidth + 18.0, gaugeTop + _gaugeHeight / 2.0), + anchor: Anchor.centerLeft, + ); + + _renderOrders(canvas, stage); + + final trigger = gameRef.touch ? 'FIRE' : 'SPACE'; + final message = switch (gameRef.phase) { + Phase.ready => ( + 'LEVEL ${stage.index + 1} · ${stage.level.name.toUpperCase()}', + '${stage.level.briefing}\n' + '${gameRef.touch ? 'Press fire to fly' : _keysHelp}', + ), + Phase.over => ('GAME OVER', '$trigger to fly again'), + _ when gameRef.banner != null => (gameRef.banner!, null), + Phase.flying when low && _blink => ('LOW FUEL', null), + Phase.flying || Phase.crashed => null, + }; + if (message != null) { + final (headline, detail) = message; + _banner.render( + canvas, + headline, + Vector2(width / 2.0, top * 0.38), + anchor: Anchor.center, + ); + if (detail != null) { + _detail.render( + canvas, + detail, + Vector2(width / 2.0, top * 0.38 + 26.0), + anchor: Anchor.topCenter, + ); + } + } + } + + /// The level and what is left of its task, top left. + void _renderOrders(Canvas canvas, Stage stage) { + final level = stage.level; + _label.render( + canvas, + 'LEVEL ${stage.index + 1} ${level.name.toUpperCase()}', + Vector2(16.0, 16.0), + ); + var y = 36.0; + for (final MapEntry(key: kind, value: wanted) in level.task.entries) { + final done = wanted - gameRef.run.stillWanted(level, kind); + final paint = done >= wanted ? _done : _label; + paint.render( + canvas, + '${RiverGame._plural(kind)} $done / $wanted', + Vector2(16.0, y), + ); + y += 18.0; + } + } +} diff --git a/examples/games/river_sortie/lib/src/levels.dart b/examples/games/river_sortie/lib/src/levels.dart new file mode 100644 index 00000000000..6a55ba2ad63 --- /dev/null +++ b/examples/games/river_sortie/lib/src/levels.dart @@ -0,0 +1,225 @@ +/// The campaign: which stretches of river make up which level, what is on +/// them, and what the pilot has to do before the level's last bridge will +/// fall. +/// +/// Plain Dart, like the course it shapes. +library; + +import 'dart:math' as math; + +import 'package:river_sortie/src/course.dart' show TargetKind; + +/// What a level's stretches are populated with. +final class Mix { + const Mix({ + required this.tanker, + required this.helicopter, + required this.depot, + this.jet = 0.0, + this.moving = 0.5, + this.speed = 1.0, + this.gunners = 0.0, + this.islands = 0.35, + this.narrowest = 6.0, + this.widest = 15.0, + this.spacing = (9.0, 14.0), + }); + + /// How the targets divide between the kinds. They need not add up to + /// one; each is a share of their sum. + final double tanker; + final double helicopter; + final double depot; + final double jet; + + /// The chance a tanker or a helicopter moves at all once woken. + final double moving; + + /// How fast everything that moves moves, against the first level. + final double speed; + + /// The share of helicopters that fire at the jet. + final double gunners; + + /// The chance a wide enough width of river has an island in it. + final double islands; + + /// The river's half-width, narrowest and widest. + final double narrowest; + final double widest; + + /// Metres between one target and the next, least and most. + final (double, double) spacing; + + /// Which kind [roll], between zero and one, picks. + TargetKind kindFor(double roll) { + final total = tanker + helicopter + depot + jet; + var at = roll * total; + for (final (kind, share) in <(TargetKind, double)>[ + (TargetKind.jet, jet), + (TargetKind.depot, depot), + (TargetKind.helicopter, helicopter), + ]) { + if (at < share) { + return kind; + } + at -= share; + } + return TargetKind.tanker; + } +} + +/// One level: a name, a line of briefing, how many bridges long it is, and +/// the task that unshields its last one. +final class Level { + const Level({ + required this.name, + required this.briefing, + required this.bridges, + required this.mix, + this.task = const {}, + }); + + final String name; + final String briefing; + final int bridges; + final Mix mix; + + /// How many of each kind have to go down before the last bridge of the + /// level can be. Empty for a level whose task is its bridges. + final Map task; + + /// Points for finishing it. + int get bonus => 1000 * bridges; +} + +/// The levels in order. After the last, the river goes on as the last one +/// for ever. +const List campaign = [ + Level( + name: 'Shakedown', + briefing: 'Bring down both bridges.', + bridges: 2, + mix: Mix( + tanker: 0.45, + helicopter: 0.2, + depot: 0.3, + moving: 0.55, + speed: 0.8, + islands: 0.25, + narrowest: 7.0, + spacing: (11.0, 16.0), + ), + ), + Level( + name: 'Supply Line', + briefing: 'Sink six tankers before the last bridge.', + bridges: 3, + task: {TargetKind.tanker: 6}, + mix: Mix(tanker: 0.55, helicopter: 0.15, depot: 0.25, moving: 0.7), + ), + Level( + name: 'Rotor Alley', + briefing: 'Down five helicopters. Some of them shoot back.', + bridges: 3, + task: {TargetKind.helicopter: 5}, + mix: Mix( + tanker: 0.25, + helicopter: 0.45, + depot: 0.22, + jet: 0.08, + moving: 0.75, + speed: 1.1, + gunners: 0.4, + ), + ), + Level( + name: 'Jet Stream', + briefing: 'Shoot down three jets as they cross.', + bridges: 3, + task: {TargetKind.jet: 3}, + mix: Mix( + tanker: 0.3, + helicopter: 0.3, + depot: 0.2, + jet: 0.18, + moving: 0.8, + speed: 1.2, + gunners: 0.5, + islands: 0.45, + narrowest: 5.5, + spacing: (8.0, 12.0), + ), + ), + Level( + name: 'Long Haul', + briefing: 'Sink eight tankers and down four helicopters. Fuel is scarce.', + bridges: 4, + task: {TargetKind.tanker: 8, TargetKind.helicopter: 4}, + mix: Mix( + tanker: 0.4, + helicopter: 0.32, + depot: 0.12, + jet: 0.14, + moving: 0.85, + speed: 1.3, + gunners: 0.6, + islands: 0.45, + narrowest: 5.5, + spacing: (8.0, 12.0), + ), + ), + Level( + name: 'Open River', + briefing: 'No more orders. See how far the fuel goes.', + bridges: 1 << 20, + mix: Mix( + tanker: 0.35, + helicopter: 0.33, + depot: 0.14, + jet: 0.16, + moving: 0.9, + speed: 1.4, + gunners: 0.7, + islands: 0.5, + narrowest: 5.0, + spacing: (7.0, 11.0), + ), + ), +]; + +/// A level placed on the river: which sections it covers. +final class Stage { + const Stage(this.index, this.level, this.first); + + /// Its place in [campaign]. + final int index; + final Level level; + + /// Its first section, and its last: the one whose bridge ends it. + final int first; + int get last => first + level.bridges - 1; +} + +/// The level section [section] belongs to. The calm water behind the start +/// counts as the first level's. +Stage stageOf(int section) { + var first = 0; + for (var i = 0; i < campaign.length; i++) { + final level = campaign[i]; + if (section < first + level.bridges || i == campaign.length - 1) { + return Stage(i, level, first); + } + first += level.bridges; + } + throw StateError('unreachable: the last level has no end'); +} + +/// The first section of level [index], clamped to the campaign. +int firstSectionOf(int index) { + var first = 0; + for (var i = 0; i < math.min(index, campaign.length - 1); i++) { + first += campaign[i].bridges; + } + return first; +} diff --git a/examples/games/river_sortie/lib/src/models.dart b/examples/games/river_sortie/lib/src/models.dart new file mode 100644 index 00000000000..98f0c9f6406 --- /dev/null +++ b/examples/games/river_sortie/lib/src/models.dart @@ -0,0 +1,447 @@ +/// Every mesh the river is drawn with that is not a model file: the valley, +/// the water, a bridge, a fuel depot, a shot, a shard of an explosion, and +/// the primitive stand-ins the craft are drawn as until their models load. +/// +/// **Colour lives in the vertices.** Each mesh here is several shapes merged +/// into one, each shape painted its own colour, and drawn with one white +/// [Material]: a tree is a trunk and a crown in one draw, and a whole stretch +/// of valley, trees and houses included, is one more. +library; + +import 'dart:math' as math; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:vector_math/vector_math.dart'; + +/// How high everything that flies flies, the jet included. +const double flightHeight = 1.7; + +// Every colour here is picked on screen and goes into vertices, which are +// linear: through `linearFromSrgb`, or a grass green comes out pastel. +final Vector4 _grassA = linearFromSrgb(0.29, 0.52, 0.19); +final Vector4 _grassB = linearFromSrgb(0.26, 0.47, 0.17); +final Vector4 _sand = linearFromSrgb(0.72, 0.63, 0.42); +final Vector4 _bed = linearFromSrgb(0.22, 0.27, 0.22); +final Vector4 _road = linearFromSrgb(0.2, 0.2, 0.22); + +Matrix4 _at(double x, double y, double z, {Quaternion? turn, Vector3? scale}) => + Matrix4.compose( + Vector3(x, y, z), + turn ?? Quaternion.identity(), + scale ?? Vector3.all(1.0), + ); + +MeshData _part(Shape shape, Vector4 colour, Matrix4 at) => + shape.build().transformed(at).withColor(colour); + +Quaternion _about(double x, double y, double z, double angle) => + Quaternion.axisAngle(Vector3(x, y, z), angle); + +/// A shape's +Y turned to -Z: a cylinder's top becomes a nose. +final Quaternion _yToNose = _about(1.0, 0.0, 0.0, -math.pi / 2.0); + +/// A shape's +Y turned to +X. +final Quaternion _yToRight = _about(0.0, 0.0, 1.0, -math.pi / 2.0); + +/// A shape's +Y turned to -X. +final Quaternion _yToLeft = _about(0.0, 0.0, 1.0, math.pi / 2.0); + +// ---------------------------------------------------------------- the valley + +/// One stretch of valley: both banks, the islands, the bed under the +/// water, the road to the bridge and everything [Section.scenery] plants, +/// in one mesh, in world coordinates. +/// +/// **Faceted on purpose.** Each quad gets its own four vertices and its own +/// normal, so the banks read as the low, hard-edged shapes of an old +/// cartridge game drawn in 3D rather than as a smooth blur. +MeshData valleyMesh(Section section) { + const step = 2.0; + final rows = (sectionLength / step).round() + 1; + final builder = MeshBuilder( + VertexLayout.standard, + reserveVertices: rows * 36, + reserveIndices: rows * 54, + ); + + List profile(double distance) { + final row = section.rowAt(distance); + // A bank's slope crosses the water line within a few centimetres of + // the row's edge, which is where the game tests the jet against it. + final z = -distance; + Vector3 p(double x, double y) => Vector3(x, y, z); + // An island narrower than its own slopes rises from the bed with its + // width, all four of its points meeting on the bed where it has none. + // At full height at every width, a river with no island in it grew a + // sand ridge down the middle: the island's two tops crossed over. + // [RiverRow.dryIsland] works out the same shape's water line. + final grown = row.islandGrown; + final crest = bedDepth + (landHeight - bedDepth) * grown; + return [ + p(-landReach, landHeight), + p(row.left - bankTop, landHeight), + p(row.left + bankUnder, bedDepth), + p(row.islandLeft - bankUnder * grown, bedDepth), + p(row.islandLeft + bankTop * grown, crest), + p(row.islandRight - bankTop * grown, crest), + p(row.islandRight + bankUnder * grown, bedDepth), + p(row.right - bankUnder, bedDepth), + p(row.right + bankTop, landHeight), + p(landReach, landHeight), + ]; + } + + // What each band between two profile points is: grass, slope, bed. + Vector4 bandColour(int band, int rowIndex) => switch (band) { + 0 || 4 || 8 => rowIndex.isEven ? _grassA : _grassB, + 1 || 3 || 5 || 7 => _sand, + _ => _bed, + }; + + var previous = profile(section.start); + for (var r = 1; r < rows; r++) { + final current = profile(section.start + r * step); + for (var band = 0; band < previous.length - 1; band++) { + _quad( + builder, + previous[band], + previous[band + 1], + current[band + 1], + current[band], + bandColour(band, r), + ); + } + previous = current; + } + + final parts = [builder.build()]; + if (section.hasBridge) { + final row = section.rowAt(section.bridgeAt); + final z = -section.bridgeAt; + final leftLength = row.left - 0.3 + landReach; + final rightLength = landReach - row.right - 0.3; + parts + ..add( + _part( + CuboidShape(size: Vector3(leftLength, 0.06, 1.8)), + _road, + _at(-landReach + leftLength / 2.0, landHeight + 0.03, z), + ), + ) + ..add( + _part( + CuboidShape(size: Vector3(rightLength, 0.06, 1.8)), + _road, + _at(landReach - rightLength / 2.0, landHeight + 0.03, z), + ), + ); + } + for (final plant in section.scenery) { + final place = _at( + plant.x, + landHeight, + -plant.distance, + turn: _about(0.0, 1.0, 0.0, plant.turn), + scale: Vector3.all(plant.scale), + ); + parts.add( + switch (plant.kind) { + SceneryKind.tree => _tree, + SceneryKind.pine => _pine, + SceneryKind.house => _house, + }.transformed(place), + ); + } + return MeshData.merge(parts); +} + +/// A flat-shaded quad, wound so its front faces up; nothing for one that +/// has collapsed to a line, which a missing island's bands do. +void _quad( + MeshBuilder builder, + Vector3 a, + Vector3 b, + Vector3 c, + Vector3 d, + Vector4 colour, +) { + final normal = (b - a).cross(d - a); + if (normal.length2 < 1e-10) { + final other = (c - b).cross(a - b); + if (other.length2 < 1e-10) { + return; + } + normal.setFrom(other); + } + normal.normalize(); + if (normal.y < 0.0) { + normal.negate(); + } + final base = builder.addVertex(position: a, normal: normal, color: colour); + builder + ..addVertex(position: b, normal: normal, color: colour) + ..addVertex(position: c, normal: normal, color: colour) + ..addVertex(position: d, normal: normal, color: colour) + ..addQuad(base, base + 1, base + 2, base + 3); +} + +final MeshData _tree = MeshData.merge([ + _part( + const CylinderShape(radiusTop: 0.1, radiusBottom: 0.14, height: 0.7), + linearFromSrgb(0.36, 0.25, 0.15), + _at(0.0, 0.35, 0.0), + ), + _part( + const SphereShape(radius: 0.75, segments: 7, rings: 5), + linearFromSrgb(0.18, 0.42, 0.14), + _at(0.0, 1.2, 0.0), + ), +]); + +final MeshData _pine = MeshData.merge([ + _part( + const CylinderShape(radiusTop: 0.08, radiusBottom: 0.12, height: 0.5), + linearFromSrgb(0.33, 0.23, 0.14), + _at(0.0, 0.25, 0.0), + ), + _part( + const ConeShape(radius: 0.65, height: 1.8, segments: 6), + linearFromSrgb(0.1, 0.32, 0.16), + _at(0.0, 1.35, 0.0), + ), +]); + +final MeshData _house = MeshData.merge([ + _part( + CuboidShape(size: Vector3(1.6, 1.0, 1.2)), + linearFromSrgb(0.88, 0.84, 0.74), + _at(0.0, 0.5, 0.0), + ), + _part( + const ConeShape(radius: 1.25, height: 0.7, segments: 4), + linearFromSrgb(0.7, 0.2, 0.15), + _at(0.0, 1.35, 0.0, turn: _about(0.0, 1.0, 0.0, math.pi / 4.0)), + ), +]); + +/// The water over one stretch, a plane at level zero centred on it. +MeshData waterMesh() => + const PlaneShape(width: valleyReach * 2.0, depth: sectionLength).build(); + +// ------------------------------------------------------------------- props + +/// How high a bridge's deck stands over the water. +const double deckHeight = landHeight + 0.45; + +/// Half a road bridge, [length] metres from its bank end at the origin out +/// along +X to the middle of the river, its deck at the origin's height. +/// +/// **Two halves, not one span**, so a bridge that is shot breaks in the +/// middle and each half falls turning about its own bank end, the way a +/// bridge whose centre is gone comes down. +MeshData bridgeHalfMesh(double length) { + final middle = length / 2.0; + return MeshData.merge([ + _part( + CuboidShape(size: Vector3(length, 0.4, 2.4)), + linearFromSrgb(0.55, 0.55, 0.52), + _at(middle, 0.0, 0.0), + ), + _part( + CuboidShape(size: Vector3(length, 0.06, 1.8)), + _road, + _at(middle, 0.22, 0.0), + ), + _part( + CuboidShape(size: Vector3(length, 0.07, 0.12)), + linearFromSrgb(0.95, 0.8, 0.2), + _at(middle, 0.24, 0.0), + ), + for (final side in [-1.0, 1.0]) + _part( + CuboidShape(size: Vector3(length, 0.3, 0.1)), + linearFromSrgb(0.75, 0.75, 0.72), + _at(middle, 0.35, side * 1.15), + ), + for (final along in [0.35, 0.9]) + _part( + const CylinderShape(radiusTop: 0.28, radiusBottom: 0.35, height: 1.6), + linearFromSrgb(0.5, 0.5, 0.48), + _at(length * along, -0.9, 0.0), + ), + ]); +} + +/// The shield over a bridge [span] metres long: two glowing rails along its +/// sides and a post at each end, centred on the origin at deck height. +MeshData shieldMesh(double span) => MeshData.merge([ + for (final side in [-1.0, 1.0]) ...[ + CuboidShape( + size: Vector3(span, 0.12, 0.12), + ).build().transformed(_at(0.0, 0.62, side * 1.25)), + for (final end in [-1.0, 1.0]) + CuboidShape( + size: Vector3(0.14, 1.1, 0.14), + ).build().transformed(_at(end * span / 2.0, 0.3, side * 1.25)), + ], +]); + +/// A floating fuel depot: a pontoon under a tank striped red and white, the +/// way it has always looked. +MeshData depotMesh() => MeshData.merge([ + _part( + CuboidShape(size: Vector3(1.9, 0.3, 2.3)), + linearFromSrgb(0.4, 0.42, 0.45), + _at(0.0, 0.1, 0.0), + ), + for (var i = 0; i < 5; i++) + _part( + CuboidShape(size: Vector3(1.5, 0.26, 1.9)), + i.isEven + ? linearFromSrgb(0.85, 0.12, 0.1) + : linearFromSrgb(0.95, 0.95, 0.92), + _at(0.0, 0.38 + i * 0.26, 0.0), + ), +]); + +/// A shot: a short bright rod, nose along -Z. +MeshData shotMesh() => CuboidShape( + size: Vector3(0.14, 0.14, 0.9), +).build().withColor(linearFromSrgb(1.0, 0.9, 0.4)); + +/// A helicopter's bullet: a long rod along Z, white, for its material to +/// colour. Long so it reads as a streak coming at the jet from eleven +/// metres up; a cube the size of a shard was lost against the water. +MeshData bulletMesh() => CuboidShape(size: Vector3(0.3, 0.3, 1.6)).build(); + +/// One shard of an explosion, white: its material gives it its colour. +MeshData shardMesh() => CuboidShape(size: Vector3.all(0.32)).build(); + +/// A puff of smoke: a coarse ball, so a darkening particle is darkest in +/// the middle, where it faces the eye, and soft at its rim. +MeshData puffMesh() => + const SphereShape(radius: 0.3, segments: 10, rings: 6).build(); + +// ------------------------------------------------- stand-ins for the models + +/// A jet, nose along -Z, about two metres long. The player's until its +/// model loads, and an enemy jet's in other colours. +MeshData jetMesh(Vector4 body, Vector4 trim) => MeshData.merge([ + _part( + const CylinderShape( + radiusTop: 0.17, + radiusBottom: 0.22, + height: 1.5, + segments: 10, + ), + body, + _at(0.0, 0.0, 0.05, turn: _yToNose), + ), + _part( + const ConeShape(radius: 0.17, height: 0.55, segments: 10), + body, + _at(0.0, 0.0, -0.975, turn: _yToNose), + ), + _part( + const SphereShape(segments: 12, rings: 8), + linearFromSrgb(0.12, 0.18, 0.3), + _at(0.0, 0.14, -0.35, scale: Vector3(0.3, 0.26, 0.7)), + ), + for (final side in [-1.0, 1.0]) ...[ + _part( + CuboidShape(size: Vector3(1.15, 0.05, 0.5)), + trim, + _at(side * 0.6, -0.02, 0.2, turn: _about(0.0, 1.0, 0.0, side * -0.35)), + ), + _part( + CuboidShape(size: Vector3(0.5, 0.04, 0.28)), + trim, + _at(side * 0.3, 0.0, 0.72, turn: _about(0.0, 1.0, 0.0, side * -0.3)), + ), + ], + _part( + CuboidShape(size: Vector3(0.05, 0.45, 0.35)), + trim, + _at(0.0, 0.25, 0.72), + ), +]); + +/// A river tanker, bow along +X, about three and a half metres long. +MeshData tankerMesh() => MeshData.merge([ + _part( + CuboidShape(size: Vector3(3.0, 0.45, 0.95)), + linearFromSrgb(0.55, 0.12, 0.1), + _at(0.0, 0.12, 0.0), + ), + _part( + const ConeShape(radius: 0.48, height: 0.55, segments: 4), + linearFromSrgb(0.55, 0.12, 0.1), + _at(1.77, 0.12, 0.0, turn: _yToRight, scale: Vector3(1.0, 1.0, 0.5)), + ), + _part( + CuboidShape(size: Vector3(2.9, 0.08, 0.85)), + linearFromSrgb(0.35, 0.4, 0.35), + _at(0.0, 0.38, 0.0), + ), + for (final along in [0.0, 0.85]) + _part( + const SphereShape(segments: 10, rings: 6), + linearFromSrgb(0.82, 0.82, 0.78), + _at(along, 0.45, 0.0, scale: Vector3(0.8, 0.5, 0.7)), + ), + _part( + CuboidShape(size: Vector3(0.6, 0.55, 0.8)), + linearFromSrgb(0.92, 0.92, 0.88), + _at(-1.0, 0.68, 0.0), + ), + _part( + const CylinderShape(radiusTop: 0.12, radiusBottom: 0.14, height: 0.45), + linearFromSrgb(0.15, 0.15, 0.15), + _at(-1.2, 1.1, 0.0), + ), +]); + +/// A helicopter without its rotor, nose along +X. +MeshData helicopterMesh() => MeshData.merge([ + _part( + const SphereShape(segments: 12, rings: 8), + linearFromSrgb(0.25, 0.38, 0.2), + _at(0.2, 0.0, 0.0, scale: Vector3(1.0, 0.75, 0.7)), + ), + _part( + const SphereShape(radius: 0.35, segments: 10, rings: 6), + linearFromSrgb(0.12, 0.18, 0.3), + _at(0.45, 0.08, 0.0), + ), + _part( + const CylinderShape(radiusTop: 0.06, radiusBottom: 0.12, height: 1.2), + linearFromSrgb(0.25, 0.38, 0.2), + _at(-0.8, 0.05, 0.0, turn: _yToLeft), + ), + _part( + CuboidShape(size: Vector3(0.25, 0.35, 0.04)), + linearFromSrgb(0.25, 0.38, 0.2), + _at(-1.35, 0.2, 0.0), + ), + for (final side in [-1.0, 1.0]) + _part( + CuboidShape(size: Vector3(1.1, 0.04, 0.05)), + linearFromSrgb(0.2, 0.2, 0.2), + _at(0.1, -0.42, side * 0.3), + ), +]); + +/// Two crossed blades, spun about their own vertical. +MeshData rotorMesh() => MeshData.merge([ + _part( + CuboidShape(size: Vector3(2.6, 0.03, 0.12)), + linearFromSrgb(0.2, 0.2, 0.2), + _at(0.0, 0.0, 0.0), + ), + _part( + CuboidShape(size: Vector3(0.12, 0.03, 2.6)), + linearFromSrgb(0.2, 0.2, 0.2), + _at(0.0, 0.0, 0.0), + ), +]); diff --git a/examples/games/river_sortie/lib/src/pieces.dart b/examples/games/river_sortie/lib/src/pieces.dart new file mode 100644 index 00000000000..8b660738ecc --- /dev/null +++ b/examples/games/river_sortie/lib/src/pieces.dart @@ -0,0 +1,505 @@ +part of 'river_game.dart'; + +/// The yaw that turns something built nose along +Z to face [x], [z]. +Quaternion _facing(double x, double z) => + Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), math.atan2(x, z)); + +Quaternion _roll(double angle) => + Quaternion.axisAngle(Vector3(0.0, 0.0, 1.0), angle); + +/// The player's jet. +/// +/// **Flame moves it; the bridge draws it.** [RiverGame] writes the jet's +/// Flame position every step, and `Object3dComponent`, flowing Flame to the +/// scene, writes that into its node, [flightHeight] over the river. The +/// bridge's [visual] node turns it up the river and banks it into a turn. +final class JetComponent extends Object3dComponent + with CollisionCallbacks, HasGameRef { + JetComponent({required super.node, required super.scene}) + : super( + plane: RiverGame.river, + direction: SyncDirection.flameToScene, + elevation: flightHeight, + size: Vector2(1.5, 1.8), + anchor: Anchor.center, + ); + + /// Radians rolled about the nose, eased towards what the stick asks for. + double bank = 0.0; + + /// Up the river is -Z. A fresh one each read: a shared quaternion is one + /// caller away from being turned in place for every other. + static Quaternion get _upRiver => _facing(0.0, -1.0); + + @override + Future onLoad() async { + await super.onLoad(); + add(RectangleHitbox()); + } + + /// The fuel depot under the jet right now, if there is one. + TargetComponent? get depotBelow { + for (final other in activeCollisions) { + if (other is TargetComponent && + other.plan.kind == TargetKind.depot && + !other.down) { + return other; + } + } + return null; + } + + /// Eases the roll towards [stick], full right being a bank of about thirty + /// degrees into the turn. + void bankTowards(double stick, double dt) { + bank += (stick * 0.55 - bank) * math.min(1.0, dt * 6.0); + visual.setRotation(_upRiver * _roll(bank)); + } + + void show() { + isVisible = true; + bank = 0.0; + visual.setRotation(_upRiver); + } + + void hide() => isVisible = false; + + /// Anything but a depot is a crash: a bridge still standing, a craft, a + /// helicopter's bullet. + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + final solid = switch (other) { + TargetComponent(:final plan, :final down) => + !down && plan.kind != TargetKind.depot, + BridgeComponent(:final down) => !down, + EnemyShotComponent() => true, + _ => false, + }; + if (solid) { + gameRef.crash(Crash.collision); + } + } +} + +/// A tanker, a helicopter, an enemy jet or a fuel depot. +/// +/// Still until the jet comes within [RiverGame.wakeRange]; then a tanker or +/// a helicopter that moves at all runs from bank to bank across its +/// channel, a helicopter that is a gunner turns after the jet and fires at +/// it, and a jet crosses the whole valley and comes round again. +/// +/// **Shot, it goes the way its kind would.** A tanker lists and sinks, +/// trailing smoke. A helicopter spins and drops into the river. A jet and +/// a depot go up at once, and a depot takes whatever is close with it. +/// From the moment it is hit its hitbox is gone: a sinking tanker is +/// scenery, not something to crash into. +final class TargetComponent extends Object3dComponent + with HasGameRef, FixedStepUpdate { + TargetComponent({ + required this.plan, + required super.node, + required super.scene, + required (double, double) channel, + }) : heading = plan.heading, + _limits = ( + math.min(channel.$1 + plan.kind.halfLength, plan.x), + math.max(channel.$2 - plan.kind.halfLength, plan.x), + ), + super( + plane: RiverGame.river, + // What flies flies at the jet's height; what floats floats. + elevation: switch (plan.kind) { + TargetKind.helicopter || TargetKind.jet => flightHeight, + TargetKind.tanker || TargetKind.depot => 0.0, + }, + direction: SyncDirection.flameToScene, + position: Vector2(plan.x, -plan.distance), + size: switch (plan.kind) { + TargetKind.tanker => Vector2(3.4, 1.2), + TargetKind.helicopter => Vector2(2.4, 1.2), + TargetKind.jet => Vector2(2.2, 1.0), + TargetKind.depot => Vector2(1.9, 2.3), + }, + anchor: Anchor.center, + ) { + face(); + } + + /// Seconds between a gunner's shots. + static const double fireInterval = 1.1; + + /// A gunner fires only at a jet this far ahead of it, and no nearer. + /// + /// **From almost as far as it wakes.** Starting at 36 with a shot every + /// 1.8 seconds, a jet at cruise went through the whole window in about + /// one interval and drew a single shot, and on the throttle often none. + /// With the first shot soon after waking, it now draws three at cruise + /// and two on the throttle. + static const (double, double) fireRange = (7.0, 46.0); + + /// Seconds from waking to a gunner's first shot. + static const double firstShot = 0.2; + + final TargetPlan plan; + + /// The stand-in helicopter's blades, spun while it flies. Null for every + /// other target, and left alone once a model has replaced them. + SceneNode? rotor; + + int heading; + bool awake = false; + + /// Hit, and going down the way its kind does. + bool down = false; + + double _spin = 0.0; + double _dying = 0.0; + double _smokeIn = 0.0; + double _fireIn = firstShot; + + /// Made with the component rather than on load, so a target hit before + /// Flame has loaded it has a hitbox to take away. + final RectangleHitbox _hitbox = RectangleHitbox( + collisionType: CollisionType.passive, + ); + + /// Where the stretch of water it runs across ends, either way: the + /// channel it was put on, less its own half-length at each end. + /// + /// **Handed in, not looked up on load.** A stretch built and dropped in + /// one step, which a restart does, has its targets loaded by Flame after + /// they have left the tree, when there is no game to ask for the course. + final (double, double) _limits; + + @override + Future onLoad() async { + await super.onLoad(); + if (!down) { + add(_hitbox); + } + } + + /// Turns what is drawn to face [heading] across the river. + void face() => visual.setRotation(_facing(heading.toDouble(), 0.0)); + + /// Takes the hit. False when it was already down, so a shot and a blast + /// arriving together count once. + bool hit() { + if (down) { + return false; + } + down = true; + _hitbox.removeFromParent(); + // Burnt: the wreck goes down charred, over the material every craft of + // its kind shares. + tint.setValues(0.35, 0.3, 0.28, 1.0); + return true; + } + + @override + void fixedUpdate(double dt) { + if (down) { + _goDown(dt); + } else { + if (!awake && + gameRef.built && + plan.distance - gameRef.distance < RiverGame.wakeRange) { + awake = true; + } + if (awake && plan.gunner) { + _hunt(dt); + } + if (awake && plan.speed > 0.0) { + _move(dt); + } + final blades = rotor; + if (blades != null) { + _spin += dt * 18.0; + blades.setRotation(Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), _spin)); + } + } + } + + void _move(double dt) { + position.x += heading * plan.speed * dt; + if (plan.kind == TargetKind.jet) { + const edge = riverReach + 8.0; + if (position.x > edge) { + position.x = -edge; + } + if (position.x < -edge) { + position.x = edge; + } + return; + } + final (lo, hi) = _limits; + if (position.x >= hi && heading > 0 || position.x <= lo && heading < 0) { + position.x = position.x.clamp(lo, hi); + heading = -heading; + face(); + } + } + + /// A gunner turns to cut across the jet's line, and fires when it has it + /// in range ahead. + void _hunt(double dt) { + if (gameRef.phase != Phase.flying) { + return; + } + final towards = (gameRef.jet.position.x - position.x).sign.toInt(); + if (towards != 0 && towards != heading) { + heading = towards; + face(); + } + _fireIn -= dt; + final ahead = plan.distance - gameRef.distance; + if (_fireIn <= 0.0 && ahead > fireRange.$1 && ahead < fireRange.$2) { + _fireIn = fireInterval; + gameRef.enemyFire(from: position.clone()); + } + } + + void _goDown(double dt) { + _dying += dt; + _smokeIn -= dt; + final yaw = _facing(heading.toDouble(), 0.0); + switch (plan.kind) { + case TargetKind.tanker: + // Lists to one side and goes under, smoking as it does. + visual.setRotation(yaw * _roll(math.min(0.55, _dying * 0.45))); + elevation = -0.45 * _dying * _dying; + if (_smokeIn <= 0.0) { + _smokeIn = 0.22; + gameRef.smoke(scenePosition..y = 0.8); + } + if (_dying > 2.4) { + removeFromParent(); + } + case TargetKind.helicopter: + // Spins about its mast and falls, smoke pouring out, until the + // river takes it. + elevation = flightHeight - 0.5 * 9.0 * _dying * _dying; + visual.setRotation( + Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), _dying * 11.0) * + _roll(0.3), + ); + if (_smokeIn <= 0.0) { + _smokeIn = 0.1; + gameRef.smoke(scenePosition..y += 0.3); + } + if (elevation <= 0.0) { + gameRef.splash(scenePosition..y = 0.1); + removeFromParent(); + } + case TargetKind.jet: + case TargetKind.depot: + removeFromParent(); + } + } +} + +/// The bridge at the end of a stretch. Solid until shot, and on the last +/// bridge of a level, shielded until the level's task is done. +/// +/// **Shot, it breaks in the middle.** It is drawn as two halves, each on a +/// pivot at its own bank end; they swing down into the river, and sink. +final class BridgeComponent extends Object3dComponent + with HasGameRef, FixedStepUpdate { + BridgeComponent({ + required this.section, + required this.span, + required this.left, + required this.right, + required this.shield, + required super.node, + required super.scene, + required super.position, + super.owns, + }) : super( + plane: RiverGame.river, + direction: SyncDirection.flameToScene, + size: Vector2(span, 2.4), + anchor: Anchor.center, + ); + + /// The index of the section it ends. + final int section; + final double span; + + /// The pivots the two halves hang from, at the bank ends. + final SceneNode left; + final SceneNode right; + + /// Glowing rails, lit while [RiverGame.shielded] says it cannot fall: a + /// pilot sees the task is not done before a shot bounces off. + final SceneNode shield; + + bool down = false; + double _falling = 0.0; + + /// Made with the component, for the reason [TargetComponent] gives. + final RectangleHitbox _hitbox = RectangleHitbox( + collisionType: CollisionType.passive, + ); + + @override + Future onLoad() async { + await super.onLoad(); + if (!down) { + add(_hitbox); + } + } + + /// Breaks it. False when it was already down. + bool collapse() { + if (down) { + return false; + } + down = true; + _hitbox.removeFromParent(); + return true; + } + + @override + void fixedUpdate(double dt) { + shield.visible = !down && gameRef.shielded(this); + if (down) { + _falling += dt; + final swing = math.min(0.8, _falling * 1.3); + final sink = math.max(0.0, _falling - 0.7) * 0.9; + left + ..setRotation(_roll(-swing)) + ..setPosition(-span / 2.0, deckHeight - sink, 0.0); + right + ..setRotation(_roll(swing)) + ..setPosition(span / 2.0, deckHeight - sink, 0.0); + // The last second under the water it fades rather than blinks out. + opacity = (3.5 - _falling).clamp(0.0, 1.0); + if (_falling > 3.5) { + isVisible = false; + } + } + } +} + +/// One shot, straight up the river until it hits something or runs out. +/// +/// An instance of the game's one batch of shots rather than a node of its +/// own: at five shots a second with a second of life, there are always a +/// handful in the air, and they are one draw. +final class ShotComponent extends InstancedObject3dComponent + with CollisionCallbacks, HasGameRef, FixedStepUpdate { + ShotComponent({ + required super.batch, + required super.position, + required this.speed, + }) : super( + plane: RiverGame.river, + elevation: flightHeight, + // Longer than the rod drawn: at thirty frames a second a shot moves + // two and a half metres a frame, and a shorter box could step over + // a tanker without ever overlapping it. + size: Vector2(0.4, 1.8), + anchor: Anchor.center, + ); + + final double speed; + double _life = 0.9; + bool _spent = false; + + @override + Future onLoad() async { + await super.onLoad(); + add(RectangleHitbox()); + } + + @override + void fixedUpdate(double dt) { + position.y -= speed * dt; + _life -= dt; + if (_life <= 0.0) { + _spend(); + } + } + + void _spend() { + if (_spent) { + return; + } + _spent = true; + removeFromParent(); + } + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + if (_spent) { + return; + } + switch (other) { + case TargetComponent(down: false): + gameRef.hitTarget(other); + case BridgeComponent(down: false): + gameRef.hitBridge(other, at: position.clone()); + default: + return; + } + _spend(); + } +} + +/// A helicopter's bullet: slow enough to see and to dodge, flying at where +/// the jet was when it was fired. +final class EnemyShotComponent extends Object3dComponent + with CollisionCallbacks, FixedStepUpdate { + EnemyShotComponent({ + required super.node, + required super.scene, + required super.position, + required this.velocity, + }) : super( + plane: RiverGame.river, + elevation: flightHeight, + direction: SyncDirection.flameToScene, + size: Vector2.all(0.5), + anchor: Anchor.center, + ); + + static const double speed = 20.0; + + final Vector2 velocity; + double _life = 2.5; + + @override + Future onLoad() async { + await super.onLoad(); + add(RectangleHitbox()); + } + + @override + void fixedUpdate(double dt) { + position.addScaled(velocity, dt); + _life -= dt; + if (_life <= 0.0 && !isRemoving) { + removeFromParent(); + } + } + + @override + void onCollisionStart( + List intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + if (other is JetComponent && !isRemoving) { + removeFromParent(); + } + } +} diff --git a/examples/games/river_sortie/lib/src/river_game.dart b/examples/games/river_sortie/lib/src/river_game.dart new file mode 100644 index 00000000000..1969d2fb22e --- /dev/null +++ b/examples/games/river_sortie/lib/src/river_game.dart @@ -0,0 +1,884 @@ +/// The `FlameGame` River Sortie is played through. +/// +/// **Flame owns the game; flutter3d draws it.** Every moving thing is a Flame +/// component on a flat map of the river, and the game's rules run the way +/// any Flame game's do: components update, hitboxes overlap, +/// `onCollisionStart` says what hit what. Each of those components is an +/// `Object3dComponent`, so its Flame position is written into a scene node +/// every frame, and the scene is what the player sees. Flame itself draws +/// only the instrument panel on top. +/// +/// The one thing not done with hitboxes is the banks. The river's edge is a +/// curve the course can answer for any point, so the jet asks +/// [Course.rowAt] whether it is over water rather than colliding with a +/// hitbox a bank would need hundreds of. +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show Canvas, Color, Paint, PaintingStyle, Path, Rect; + +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart' show FlameGame; +import 'package:flame/input.dart' show HudButtonComponent; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/painting.dart' + show EdgeInsets, FontWeight, Shadow, TextStyle; +import 'package:flutter/services.dart' show KeyEvent, LogicalKeyboardKey; +import 'package:flutter/widgets.dart' show KeyEventResult; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_audio_core/flutter3d_audio_core.dart'; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_particles/flutter3d_particles.dart' + show + ConeEmitter, + MeshParticleContributor, + ParticleAffector, + ParticleColorOverLife, + ParticleDrag, + ParticleEffect, + ParticleFade, + ParticleGravity, + ParticleSizeOverLife, + ParticleSystem, + Range, + SphereEmitter; +import 'package:flutter3d_sim/flutter3d_sim.dart' + show GameAction, GameRandom, InputState; +import 'package:river_sortie/src/audio/audio.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/levels.dart'; +import 'package:river_sortie/src/models.dart'; +import 'package:river_sortie/src/rules.dart'; +import 'package:river_sortie/src/sprites.dart'; + +part 'craft.dart'; +part 'hud.dart'; +part 'pieces.dart'; +part 'sounds.dart'; +part 'staging.dart'; + +/// Where a run is. +enum Phase { + /// On the water at the start of a stretch, waiting for the player. + ready, + flying, + + /// Down, and the pause before the next jet. + crashed, + + /// No jets left. + over, +} + +/// What brought the last jet down. +enum Crash { bank, collision, fuel } + +final class RiverGame extends FlameGame + with HasFlutter3d, HasFixedStep, KeyboardEvents, HasCollisionDetection { + /// [models] loads the craft models over the primitives once the river is + /// open; the tests leave it off, having no app bundle to load them from. + /// [billboards] draws the reeds on the banks and the flash of a blast, + /// Flame sprites standing in the scene, once they have been drawn. + RiverGame({ + int seed = defaultSeed, + this.models = false, + this.billboards = false, + this.speakers, + }) : course = Course(seed: seed) { + clearColor.setValues(_haze.x, _haze.y, _haze.z, 1.0); + } + + final bool models; + final bool billboards; + + /// The reeds and the flash, once drawn; null until then, and in a game + /// without [billboards]. + RiverSprites? sprites; + + /// The one texture and material each picture is drawn with, and the + /// cards of its frames, shared by every billboard. + late final BillboardAtlas atlas = BillboardAtlas(device); + + /// Draws the pictures, then dresses the banks of every stretch already + /// standing, as the models dress the craft already flying. Once, however + /// often it is asked: a second dressing would stand every reed twice. + Future drawSprites() => _drawingSprites; + + late final Future _drawingSprites = _drawSprites(); + + Future _drawSprites() async { + final drawn = await RiverSprites.draw(); + if (!has3d) { + return; + } + sprites = drawn; + for (final stretch in _stretches.chunks) { + stretch.reeds.addAll(_reedsAlong(stretch.index)); + stretch.targets + .where((target) => target.plan.kind == TargetKind.depot) + .forEach(signDepot); + } + } + + @override + void onClose3d() { + atlas.dispose(drawing: renderer); + super.onClose3d(); + } + + /// The sky, and the haze the far end of the river fades into: one colour, + /// so the valley has no edge where the land stops being drawn. + static Vector3 get _haze => Vector3(0.27, 0.48, 0.78); + + @override + CameraNode createCamera3d() => CameraNode( + name: 'eye', + projection: const PerspectiveProjection( + fovYRadians: 0.85, + near: 0.5, + far: 400.0, + ), + ); + + @override + RenderSettings renderSettings() => + RenderSettings(fog: FogSettings(color: _haze, density: 0.004)); + + /// Opens the river, and dresses its craft when there are models to load. + @override + void onOpen3d() { + build(device, scene); + if (models) { + unawaited(dressWithModels()); + } + if (billboards) { + unawaited(drawSprites()); + } + } + + /// The trigger. `flutter3d_sim` names movement and a few common verbs; a + /// game adds its own the same way. + static const GameAction fire = GameAction('fire'); + + /// Metres per second up the river: cruising, pushed forward, held back. + static const double cruiseSpeed = 16.0; + static const double fastSpeed = 26.0; + static const double slowSpeed = 9.0; + + /// Metres per second across it at full stick. + static const double sideSpeed = 10.0; + + /// A shot's own speed, on top of the jet's. + static const double shotSpeed = 60.0; + static const double shotInterval = 0.2; + + /// How close the jet has to come before a tanker or a helicopter starts + /// to move. Until then it keeps its place, so a stretch looks the same + /// every time it is flown into. + static const double wakeRange = 48.0; + + /// Seconds between a crash and the next jet. + static const double crashPause = 2.2; + + /// Half the jet's wingspan and the length ahead of its centre the bank + /// test uses: a little under the drawing, so a wingtip over the sand is a + /// near miss rather than a crash. + static const double wingReach = 0.75; + static const double noseReach = 0.9; + + /// The water, which everything is placed on: what floats at its own level, + /// what flies at an `elevation` of [flightHeight] above it. + static final BridgePlane river = BridgePlane.ground(); + + final Course course; + RunState run = RunState(); + Phase phase = Phase.ready; + + final InputState input = InputState(); + late final FlameInputBridge inputBridge = FlameInputBridge( + bindings: Bindings({ + for (final key in [ + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.keyW, + ]) + InputSource.key(key.keyId): GameAction.moveForward, + for (final key in [ + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.keyS, + ]) + InputSource.key(key.keyId): GameAction.moveBack, + for (final key in [ + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.keyA, + ]) + InputSource.key(key.keyId): GameAction.moveLeft, + for (final key in [ + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.keyD, + ]) + InputSource.key(key.keyId): GameAction.moveRight, + for (final key in [ + LogicalKeyboardKey.space, + LogicalKeyboardKey.enter, + ]) + InputSource.key(key.keyId): fire, + }), + inputState: input, + ); + + late final JetComponent jet; + late final GraphicsDevice _device; + late final Scene _scene; + late final _Kit _kit; + + /// Every shot of the jet's in the air, drawn in one call. + late final InstancedMeshNode _shots; + + /// Whether [build] has run. Flame loads the game before the 3D device is + /// open, and until then there is no jet to fly. + bool built = false; + + /// Metres per second up the river, right now. + double speed = 0.0; + + /// How far up the river the jet is. + double get distance => -jet.position.y; + + /// The stretches of river built around the jet, by section index. + late final ChunkStreamer<_Stretch> _stretches = ChunkStreamer<_Stretch>( + build: _buildStretch, + drop: _dropStretch, + ); + + /// Every target in play, for the tests and the models that load late. + Iterable get targets => + _stretches.chunks.expand((stretch) => stretch.targets); + + Iterable get bridges => + _stretches.chunks.map((stretch) => stretch.bridge).nonNulls; + + /// The craft models, and every visual node waiting for or wearing one. + late final ModelWardrobe wardrobe; + + double _crashTimer = 0.0; + double _shotCooldown = 0.0; + + /// The on-screen stick and trigger, on a device with no keys. + JoystickComponent? joystick; + bool get touch => joystick != null; + + @override + Future onLoad() async { + await super.onLoad(); + camera.viewport.add(RiverHud()); + addAll([ + sound, + _engineLoop, + _refuelLoop, + _alarmLoop, + // Closes the input step once everything this frame has read it. + inputBridge.stepEnd(), + ]); + } + + /// The renderer the 3D layer is drawn with: what a stretch's meshes go + /// back through, so no frame still in flight is drawing them when they + /// do, and what the blasts are drawn with. + @override + void onRenderer3d(Renderer drawing) { + blasts.drawWith(drawing, _kit.shard); + soot.drawWith(drawing, _kit.puff, blend: MeshParticleContributor.darkening); + } + + /// Fire, sparks and spray: everything on screen that glows or shines, + /// one pool and one draw. + late final Particles3dComponent blasts; + + /// Smoke: everything that darkens what is behind it, another pool and + /// another draw. + late final Particles3dComponent soot; + + static final TextPaint _popPaint = TextPaint( + style: const TextStyle( + color: Color(0xFFF4D35E), + fontSize: 18.0, + fontWeight: FontWeight.w700, + shadows: [Shadow(blurRadius: 4.0, color: Color(0xAA000000))], + ), + ); + + /// The points [points] just scored, over [at] in the scene: drawn by + /// Flame in its viewport, rising and gone in under a second. + void _popScore(int points, Vector3 at) { + final screen = projector.toScreen(at); + if (screen == null) { + return; + } + camera.viewport.add( + TextComponent( + text: '+$points', + textRenderer: _popPaint, + position: screen, + anchor: Anchor.center, + )..addAll([ + MoveByEffect(Vector2(0.0, -48.0), EffectController(duration: 0.8)), + RemoveEffect(delay: 0.8), + ]), + ); + } + + /// Lets go of [mesh]: after the frames in flight when there is a renderer, + /// at once when there is none and so nothing in flight. + void _release(DeviceMesh mesh) { + final drawing = renderer; + if (drawing != null) { + drawing.releaseMeshAfterFrame(mesh); + } else { + _device + ..releaseGeometry(mesh.vertices) + ..releaseGeometry(mesh.indices); + } + } + + /// Behind the jet and above it, looking up the river; made with the + /// river, in `build`. Shaken when the jet goes down or a depot goes up. + /// + /// **Follows the jet up the river, and only part way across.** A camera + /// locked to the jet's `x` turned the whole valley with every dodge; one + /// that did not follow at all lost the jet off a narrow screen. A third + /// of the way is enough to keep both banks in view and still feel the + /// jet slide across. + /// + /// **Aimed so the jet sits in the lower third, above the panel.** Looking + /// further up the river put the jet four fifths of the way down the + /// frame, behind Flame's instrument panel, where nobody could see it bank. + late final ChaseCamera chase; + + /// What brought the last jet down, for the tests and for anyone asking. + Crash? lastCrash; + + /// The jet is down: over the land, into something, or dry. + void crash(Crash cause) { + if (phase != Phase.flying) { + return; + } + lastCrash = cause; + phase = Phase.crashed; + _crashTimer = crashPause; + _say(Sounds.crash); + chase.rig.shake(0.5); + jet.hide(); + final at = jet.scenePosition; + fireball(at, size: 1.3); + if (cause == Crash.bank) { + smoke(at); + } + } + + /// How far a depot going up reaches: whatever is this close goes with + /// it, the jet included. + static const double depotBlast = 5.5; + + /// A shot, or a depot going up, reached [target]: it scores, it counts + /// towards the level's task, and it goes down the way its kind does. + void hitTarget(TargetComponent target) { + if (!target.hit()) { + return; + } + final kind = target.plan.kind; + final stage = stageOf(course.sectionIndexAt(target.plan.distance)); + final wasDone = run.taskDone(stage.level); + run + ..award(kind.points) + ..count(kind); + if (!wasDone && run.taskDone(stage.level)) { + say('TASK DONE · THE LAST BRIDGE IS OPEN'); + } + + final at = target.scenePosition; + _sayAt(kind == TargetKind.depot ? Sounds.bigBoom : Sounds.boom, at); + _popScore(kind.points, at); + switch (kind) { + case TargetKind.tanker: + fireball(at..y = 0.9, size: 0.7); + splash(at..y = 0.1); + case TargetKind.helicopter: + fireball(at, size: 0.6); + case TargetKind.jet: + fireball(at, size: 1.1); + case TargetKind.depot: + fireball(at..y = 1.2, size: 1.6); + chase.rig.shake(0.25); + _detonate(target); + } + } + + /// A depot going up takes its neighbours with it, and a jet refuelling + /// over it. + void _detonate(TargetComponent depot) { + for (final other in targets.toList()) { + if (!other.down && + other.position.distanceTo(depot.position) < depotBlast) { + hitTarget(other); + } + } + if (jet.position.distanceTo(depot.position) < depotBlast * 0.5) { + crash(Crash.collision); + } + } + + /// Whether [bridge] is the last of its level and the level's task is not + /// done yet. + bool shielded(BridgeComponent bridge) { + final stage = stageOf(bridge.section); + return bridge.section == stage.last && !run.taskDone(stage.level); + } + + /// What the task still wants, as the panel and the shield say it. + String stillWanted(Level level) => [ + for (final kind in level.task.keys) + if (run.stillWanted(level, kind) > 0) + '${run.stillWanted(level, kind)} ${_plural(kind)}', + ].join(', '); + + static String _plural(TargetKind kind) => switch (kind) { + TargetKind.tanker => 'TANKERS', + TargetKind.helicopter => 'HELICOPTERS', + TargetKind.depot => 'DEPOTS', + TargetKind.jet => 'JETS', + }; + + /// A shot reached [bridge] at [at]. A shielded one throws sparks and + /// stands; any other breaks and falls, and the next jet starts past it. + /// The last of a level finishes the level. + void hitBridge(BridgeComponent bridge, {required Vector2 at}) { + if (shielded(bridge)) { + final struck = river.to3d(at, at: flightHeight); + sparks(struck); + _sayAt(Sounds.spark, struck); + say('SHIELDED · ${stillWanted(stageOf(bridge.section).level)} TO GO'); + return; + } + if (!bridge.collapse()) { + return; + } + _sayAt(Sounds.bigBoom, bridge.scenePosition); + run + ..award(500) + ..bridgeDown(bridge.section); + _popScore(500, bridge.scenePosition..y = deckHeight); + for (final along in [-0.3, 0.0, 0.3]) { + final burst = bridge.scenePosition + ..x += bridge.span * along + ..y = deckHeight; + fireball(burst, size: 0.8); + } + splash(bridge.scenePosition..y = 0.1, size: 1.4); + + final stage = stageOf(bridge.section); + if (bridge.section == stage.last) { + run.finishLevel(stage.level); + _say(Sounds.level); + say('LEVEL COMPLETE · +${stage.level.bonus}', seconds: 3.5); + } + } + + /// A helicopter at [from] fires at where the jet is now: a red flash at + /// its nose, and a streak laid along the way it flies. + void enemyFire({required Vector2 from}) { + final aim = (jet.position - from)..normalize(); + final muzzle = from + aim * 1.4; + _sayAt(Sounds.tracer, river.to3d(from, at: flightHeight)); + _muzzleFlash(river.to3d(muzzle, at: flightHeight)); + add( + EnemyShotComponent( + // The rod turned inside a node of its own: the component writes + // the outer node's place, and the streak keeps its heading. + node: SceneNode(name: 'tracer') + ..add( + MeshNode(_kit.bullet, _kit.tracer) + ..setRotation(_facing(aim.x, aim.y)), + ), + scene: _scene, + position: muzzle, + velocity: aim * EnemyShotComponent.speed, + ), + ); + } + + void _muzzleFlash(Vector3 at) => blasts.system.burst( + ParticleEffect( + count: 10, + emitter: const SphereEmitter(speed: Range(1.5, 3.5)), + lifetime: const Range(0.2, 0.3), + size: const Range(0.5, 0.8), + color: _muzzle, + affectors: const [ParticleSizeOverLife()], + ), + at, + ); + + /// Fire: glowing shards thrown up and out, falling, shrinking, dimming + /// from orange to a dull red, round the flash of the blast itself. + void fireball(Vector3 at, {double size = 1.0}) { + _flash(at, size); + _shards(at, size); + } + + /// The blast's own flash, a Flame sprite animation played once where it + /// happened, facing the camera, gone when it has played. + void _flash(Vector3 at, double size) { + final drawn = sprites; + if (drawn == null) { + return; + } + final tall = 3.2 * size; + add( + SpriteBillboardComponent( + animation: drawn.flash(), + atlas: atlas, + device: _device, + scene: _scene, + plane: river, + cardHeight: tall, + upright: false, + removeOnFinish: true, + position: river.to2d(at), + elevation: at.y - tall / 2.0, + ), + ); + } + + void _shards(Vector3 at, double size) => blasts.system.burst( + ParticleEffect( + count: (14 * size).round(), + emitter: ConeEmitter( + speed: Range(2.5 * size, 6.5 * size), + halfAngleDegrees: 80.0, + ), + lifetime: const Range(0.6, 0.9), + size: Range(0.8 * size, 1.1 * size), + color: _flame, + affectors: [ + const ParticleGravity(-14.0), + ParticleColorOverLife(_flame, _ember), + const ParticleSizeOverLife(), + ], + ), + at, + ); + + static Vector4 get _flame => Vector4(4.0, 2.2, 0.6, 1.0); + static Vector4 get _ember => Vector4(1.2, 0.2, 0.05, 1.0); + static Vector4 get _spark => Vector4(4.0, 3.4, 1.6, 1.0); + static Vector4 get _muzzle => Vector4(4.0, 0.9, 0.4, 1.0); + static Vector4 get _spray => Vector4(0.7, 0.8, 0.9, 1.0); + + /// How much of what is behind it a puff of smoke takes away, fresh and + /// as it thins out. + static Vector4 get _sootThick => Vector4(0.3, 0.32, 0.38, 1.0); + static Vector4 get _sootThin => Vector4(0.08, 0.08, 0.1, 1.0); + + /// A puff of smoke, rising slowly, swelling and thinning out: drawn by + /// [soot], which takes its colour out of what is behind it. + /// + /// **Faint on its own.** A burning craft puts out a puff every fraction of + /// a second and the puffs overlap, and darkening multiplies: a puff that + /// took three quarters of the light made a column of black. One that + /// takes a third at its thickest builds to a dark grey where the column + /// is dense and stays thin at its edges. It takes a little more blue than + /// red, so the smoke over the water reads grey-brown, not navy. + void smoke(Vector3 at) => soot.system.burst( + ParticleEffect( + count: 2, + emitter: const ConeEmitter( + speed: Range(0.5, 1.3), + halfAngleDegrees: 30.0, + ), + lifetime: const Range(1.6, 2.2), + size: const Range(0.9, 1.2), + color: _sootThick, + affectors: [ + const ParticleGravity(0.6), + const ParticleDrag(1.5), + ParticleColorOverLife(_sootThick, _sootThin), + const ParticleSizeOverLife(from: 0.5, to: 2.4), + const ParticleFade(startsAt: 0.5), + ], + ), + at, + ); + + /// White water thrown up where something meets the river. + void splash(Vector3 at, {double size = 1.0}) => blasts.system.burst( + ParticleEffect( + count: (12 * size).round(), + emitter: ConeEmitter( + speed: Range(3.0 * size, 9.0 * size), + halfAngleDegrees: 35.0, + ), + lifetime: const Range(0.6, 0.8), + size: const Range.exact(0.6), + color: _spray, + affectors: const [ + ParticleGravity(-18.0), + ParticleSizeOverLife(), + ], + ), + at, + ); + + /// A shot glancing off something it cannot break. + void sparks(Vector3 at) => blasts.system.burst( + ParticleEffect( + count: 6, + emitter: const SphereEmitter(speed: Range(2.0, 4.0)), + lifetime: const Range(0.25, 0.35), + size: const Range.exact(0.35), + color: _spark, + affectors: const [ + ParticleGravity(-14.0), + ParticleSizeOverLife(), + ], + ), + at, + ); + + /// What the panel says across the middle, and for how long more. + String? banner; + double _bannerFor = 0.0; + + void say(String text, {double seconds = 2.5}) { + banner = text; + _bannerFor = seconds; + } + + /// The level the jet is on. + Stage get stage => stageOf(course.sectionIndexAt(distance)); + + /// The level last announced, so the next is announced as the jet flies + /// into it. + int _announced = -1; + + /// Whether a depot is filling the tank this step. + bool refuelling = false; + + /// The game's sound: silent until [AudioSceneComponent.open], which the + /// first take-off asks for through [onFirstFlight]. + late final AudioSceneComponent sound = AudioSceneComponent( + bank: Sounds.all, + opener: speakers, + ); + + /// How the speakers open, as the tests give a silent pair they can listen + /// to. Without one the game is silent; see [AudioSceneComponent.opener]. + final Future Function()? speakers; + + final SoundEmitterComponent _engineLoop = SoundEmitterComponent( + Sounds.engine, + playing: false, + ); + final SoundEmitterComponent _refuelLoop = SoundEmitterComponent( + Sounds.refuel, + playing: false, + ); + final SoundEmitterComponent _alarmLoop = SoundEmitterComponent( + Sounds.lowFuel, + playing: false, + ); + int _reserveHeard = RunState.startingReserve; + + /// Called once, the first time the jet takes off. + /// + /// **The moment to open the speakers.** Taking off is the player's first + /// key, touch or button, and a browser lets a page make a sound only after + /// one; a game that opened its audio at launch has its first sound refused. + void Function()? onFirstFlight; + bool _flown = false; + + void _fire() { + _say(Sounds.shot); + add( + ShotComponent( + batch: _shots, + speed: shotSpeed + speed, + position: jet.position + Vector2(0.0, -1.3), + ), + ); + } + + /// One step of flight: the stick, the throttle, the fuel, the trigger, + /// and the banks. + void _fly(double dt) { + final axis = input.moveAxis; + final wanted = axis.y > 0.2 + ? fastSpeed + : axis.y < -0.2 + ? slowSpeed + : cruiseSpeed; + speed += (wanted - speed) * math.min(1.0, dt * 3.0); + jet + ..position.x += axis.x * sideSpeed * dt + ..position.y -= speed * dt + ..bankTowards(axis.x, dt); + + run.burn(dt); + refuelling = jet.depotBelow != null; + if (refuelling) { + run.refuel(dt); + } + + final current = stage; + if (current.index != _announced) { + _announced = current.index; + say( + 'LEVEL ${current.index + 1} · ${current.level.name.toUpperCase()}', + seconds: 3.0, + ); + } + + _shotCooldown -= dt; + if (input.held(fire) && _shotCooldown <= 0.0) { + _fire(); + _shotCooldown = shotInterval; + } + + final x = jet.position.x; + final overWater = + course.rowAt(distance).isWater(x, halfWidth: wingReach) && + course.rowAt(distance + noseReach).isWater(x, halfWidth: 0.15); + if (!overWater) { + crash(Crash.bank); + } + if (run.outOfFuel) { + crash(Crash.fuel); + } + _ensureStretches(); + } + + @override + void update(double dt) { + if (!built) { + super.update(dt); + return; + } + if (banner != null) { + _bannerFor -= dt; + if (_bannerFor <= 0.0) { + banner = null; + } + } + super.update(dt); + } + + /// The run, in fixed steps: the same flight at any frame rate. See + /// [HasFixedStep]. + @override + void fixedUpdate(double dt) { + if (!built) { + return; + } + _step(dt); + // After the step and before the children update: the game's sound is + // one of them and mixes when it does, and a loop turned on after the + // mix is heard a frame late. + _listen(); + } + + void _step(double dt) { + switch (phase) { + case Phase.ready: + if (input.pressed(fire) || input.moveAxis.length2 > 0.04) { + phase = Phase.flying; + _shotCooldown = shotInterval; + if (!_flown) { + _flown = true; + onFirstFlight?.call(); + } + } + case Phase.flying: + _fly(dt); + case Phase.crashed: + _crashTimer -= dt; + if (_crashTimer <= 0.0) { + if (run.nextJet()) { + _restart(); + } else { + phase = Phase.over; + } + } + case Phase.over: + if (input.pressed(fire)) { + run = RunState(); + _restart(); + } + } + } + + /// The stick bottom left and the trigger bottom right, above the panel. + /// + /// **Flame's own components, feeding the same [InputState] the keys do,** + /// through the input bridge: `followJoystick` writes the stick's + /// deflection where a gamepad's stick would go, and `bindButton` holds + /// [fire] while the trigger is down. The jet never learns which it was. + void addTouchControls() { + final stick = JoystickComponent( + knob: CircleComponent( + radius: 26.0, + paint: Paint()..color = const Color(0xCCFFFFFF), + ), + background: CircleComponent( + radius: 66.0, + paint: Paint()..color = const Color(0x44FFFFFF), + ), + margin: const EdgeInsets.only(left: 40.0, bottom: 110.0), + ); + final trigger = HudButtonComponent( + button: CircleComponent( + radius: 42.0, + paint: Paint()..color = const Color(0x88FF5A3C), + ), + margin: const EdgeInsets.only(right: 48.0, bottom: 120.0), + ); + inputBridge.bindButton(trigger, fire); + joystick = stick; + camera.viewport.addAll([stick, trigger]); + add(inputBridge.followJoystick(stick)); + } + + @override + KeyEventResult onKeyEvent( + KeyEvent event, + Set keysPressed, + ) => inputBridge.onGameKeyEvent(event, keysPressed); +} + +/// One stretch of river as it stands in the game: its two scene nodes, the +/// buffers to release with it, and the components living on it. +final class _Stretch { + _Stretch({ + required this.index, + required this.valley, + required this.water, + required this.geometry, + required this.bridge, + required this.targets, + required this.reeds, + }); + + final int index; + final MeshNode valley; + final MeshNode water; + final List geometry; + final BridgeComponent? bridge; + final List targets; + + /// The reeds and bushes on its banks, when there are sprites to draw. + final List reeds; +} diff --git a/examples/games/river_sortie/lib/src/rules.dart b/examples/games/river_sortie/lib/src/rules.dart new file mode 100644 index 00000000000..78637e8bfd5 --- /dev/null +++ b/examples/games/river_sortie/lib/src/rules.dart @@ -0,0 +1,92 @@ +/// The rules of a run, kept apart from everything that draws or moves it: +/// the score, the jets left, the fuel, and the bridge a lost jet starts +/// again from. +library; + +import 'dart:math' as math; + +import 'package:river_sortie/src/course.dart' show TargetKind; +import 'package:river_sortie/src/levels.dart'; + +/// One run, from the first take-off to the last jet lost. +final class RunState { + /// Jets in reserve at the start, not counting the one flying. + static const int startingReserve = 3; + + /// Another jet in reserve every this many points. + static const int extraJetEvery = 10000; + + /// A full tank lasts this many seconds of flying. + static const double tankSeconds = 38.0; + + /// A depot fills an empty tank in this many seconds over it. + static const double refillSeconds = 2.4; + + /// Below this the gauge warns. + static const double lowFuel = 0.25; + + int score = 0; + int reserve = startingReserve; + + /// From empty at zero to full at one. + double fuel = 1.0; + + /// The section a lost jet starts again from: the one past the last bridge + /// it brought down. + int checkpoint = 0; + + int _nextExtraJet = extraJetEvery; + + /// What has gone down on the level being flown, by kind. Kept through a + /// lost jet: what was shot stays shot, even though the river puts it back. + final Map tally = {}; + + /// One more [kind] down on this level. + void count(TargetKind kind) => tally[kind] = (tally[kind] ?? 0) + 1; + + /// How many more of [kind] [level]'s task wants. + int stillWanted(Level level, TargetKind kind) => + math.max(0, (level.task[kind] ?? 0) - (tally[kind] ?? 0)); + + /// Whether [level]'s task is done, and its last bridge can fall. + bool taskDone(Level level) => + level.task.keys.every((kind) => stillWanted(level, kind) == 0); + + /// [level] is flown: its bonus, and a clean tally for the next one. + void finishLevel(Level level) { + award(level.bonus); + tally.clear(); + } + + bool get outOfFuel => fuel <= 0.0; + bool get fuelLow => fuel < lowFuel; + + /// Adds [points], and a jet in reserve for every threshold they carry the + /// score past. + void award(int points) { + score += points; + while (score >= _nextExtraJet) { + reserve++; + _nextExtraJet += extraJetEvery; + } + } + + void burn(double dt) => fuel = math.max(0.0, fuel - dt / tankSeconds); + + void refuel(double dt) => fuel = math.min(1.0, fuel + dt / refillSeconds); + + /// The bridge at the end of section [index] is down: the next jet starts + /// past it. + void bridgeDown(int index) => checkpoint = math.max(checkpoint, index + 1); + + /// Takes a jet out of reserve for the next attempt, with a full tank. + /// False when there is none left and the run is over. + bool nextJet() { + if (reserve == 0) { + return false; + } + reserve--; + fuel = 1.0; + return true; + } +} diff --git a/examples/games/river_sortie/lib/src/sounds.dart b/examples/games/river_sortie/lib/src/sounds.dart new file mode 100644 index 00000000000..6a9a3e946ab --- /dev/null +++ b/examples/games/river_sortie/lib/src/sounds.dart @@ -0,0 +1,171 @@ +part of 'river_game.dart'; + +/// Everything River Sortie can say, written by `tool/make_sounds.py`. +/// +/// **The engine sits under everything else.** It never stops, so it is the +/// quietest thing in the mix: a drone as loud as a shot buries the shot, and +/// the first version of this bank did exactly that. +/// +/// **The jet's sounds are flat, the river's are placed.** The engine, a +/// shot, the refuelling tone, the alarm and the crash are the player's own +/// and are heard the same wherever the camera is, as on a cartridge with one +/// speaker. A tanker going up, a bridge falling, sparks off a shield and a +/// helicopter's burst happen somewhere on the river, and are heard from +/// there: quieter far up it, and from the side of the screen they are on. +abstract final class Sounds { + /// How a sound out on the river carries: full within twenty metres of the + /// camera, halving with each doubling of distance past that, and gone + /// past the far end of what the river builds ahead. + static const Attenuation _outThere = InverseRolloff( + reference: 20.0, + maximum: 220.0, + ); + + static const SoundDef engine = SoundDef( + name: 'engine', + asset: 'assets/sounds/engine.wav', + loop: true, + gain: 0.16, + attenuation: NoAttenuation(), + priority: 3, + maxInstances: 1, + ); + + static const SoundDef refuel = SoundDef( + name: 'refuel', + asset: 'assets/sounds/refuel.wav', + loop: true, + gain: 0.5, + attenuation: NoAttenuation(), + priority: 2, + maxInstances: 1, + ); + + static const SoundDef lowFuel = SoundDef( + name: 'low-fuel', + asset: 'assets/sounds/low_fuel.wav', + loop: true, + gain: 0.4, + attenuation: NoAttenuation(), + priority: 3, + maxInstances: 1, + ); + + static const SoundDef shot = SoundDef( + name: 'shot', + asset: 'assets/sounds/shot.wav', + gain: 0.6, + attenuation: NoAttenuation(), + maxInstances: 3, + rateVariance: 0.04, + ); + + static const SoundDef boom = SoundDef( + name: 'boom', + asset: 'assets/sounds/boom.wav', + gain: 0.9, + attenuation: _outThere, + priority: 1, + maxInstances: 3, + rateVariance: 0.08, + ); + + static const SoundDef bigBoom = SoundDef( + name: 'big-boom', + asset: 'assets/sounds/big_boom.wav', + attenuation: _outThere, + priority: 2, + maxInstances: 2, + ); + + static const SoundDef crash = SoundDef( + name: 'crash', + asset: 'assets/sounds/crash.wav', + attenuation: NoAttenuation(), + priority: 5, + maxInstances: 1, + ); + + static const SoundDef spark = SoundDef( + name: 'spark', + asset: 'assets/sounds/spark.wav', + gain: 0.45, + attenuation: _outThere, + maxInstances: 2, + ); + + static const SoundDef tracer = SoundDef( + name: 'tracer', + asset: 'assets/sounds/tracer.wav', + gain: 0.35, + attenuation: _outThere, + maxInstances: 3, + ); + + static const SoundDef level = SoundDef( + name: 'level', + asset: 'assets/sounds/level.wav', + gain: 0.5, + attenuation: NoAttenuation(), + priority: 4, + maxInstances: 1, + ); + + static const SoundDef extraJet = SoundDef( + name: 'extra-jet', + asset: 'assets/sounds/extra_jet.wav', + gain: 0.5, + attenuation: NoAttenuation(), + priority: 4, + maxInstances: 1, + ); + + static final SoundBank all = SoundBank([ + engine, + refuel, + lowFuel, + shot, + boom, + bigBoom, + crash, + spark, + tracer, + level, + extraJet, + ]); +} + +/// The game's voice: the three loops its state holds open, and a one-shot +/// for each event, through `flame_flutter3d_audio`. +/// +/// The loops are [SoundEmitterComponent]s in the game and the one-shots go +/// into [RiverGame.sound]; until the speakers open, both play into a silent +/// scene, and the loops move onto the speakers when they do. +extension RiverGameSound on RiverGame { + /// A sound of the jet's own, or of the game: heard the same wherever the + /// camera is. + void _say(SoundDef def) => sound.play(def); + + /// A sound out on the river: quieter the further from the camera, and on + /// the side of the screen it happened on. + void _sayAt(SoundDef def, Vector3 at) => sound.play(def, at: at); + + /// Holds each loop open by the state it stands for, bends the engine with + /// the throttle, and hails an extra jet. + void _listen() { + final flying = phase == Phase.flying; + _engineLoop.playing = flying; + _refuelLoop.playing = flying && refuelling; + _alarmLoop.playing = flying && run.fuelLow && !refuelling; + final throttle = + ((speed - RiverGame.slowSpeed) / + (RiverGame.fastSpeed - RiverGame.slowSpeed)) + .clamp(0.0, 1.0); + _engineLoop.rate = 0.75 + 0.6 * throttle; + + if (run.reserve > _reserveHeard) { + _say(Sounds.extraJet); + } + _reserveHeard = run.reserve; + } +} diff --git a/examples/games/river_sortie/lib/src/sprites.dart b/examples/games/river_sortie/lib/src/sprites.dart new file mode 100644 index 00000000000..903cd080e6e --- /dev/null +++ b/examples/games/river_sortie/lib/src/sprites.dart @@ -0,0 +1,193 @@ +/// The river's flat pictures, drawn here in code as its sounds are made by a +/// script: reeds on the banks, the flash of a blast, the word on a depot. +library; + +import 'dart:ui' as ui; + +import 'package:flame/components.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart' show BillboardAtlas; +import 'package:flutter/painting.dart' show FontWeight, TextStyle; + +/// Reeds and bushes to stand along the banks, and a blast's flash: Flame +/// sprites, drawn as pixel art at start-up rather than read from files. +/// +/// **Pixel art, drawn by hand.** Each picture is a few dozen coloured +/// squares on a transparent sheet, sampled nearest in the scene, so it keeps +/// its squares however near the camera comes. No image files: the pictures +/// are the code below, and changing one is changing a colour here. +final class RiverSprites { + RiverSprites._(this.banks, this._flashSheet, this.fuel); + + /// Draws every picture. Asynchronous only because an image is. + static Future draw() async { + final banks = await _drawBanks(); + final flash = await _drawFlash(); + final fuel = await BillboardAtlas.spriteOfText('FUEL', _lettering); + return RiverSprites._( + [ + for (var i = 0; i < _bankKinds; i++) + Sprite( + banks, + srcPosition: Vector2(i * _bankWidth.toDouble(), 0.0), + srcSize: Vector2(_bankWidth.toDouble(), _bankHeight.toDouble()), + ), + ], + flash, + fuel, + ); + } + + /// What grows on a bank: tall reeds, reeds with a bulrush, a round bush. + final List banks; + + /// The word a depot has always said, written by Flame's text paint: to + /// be drawn smooth, as lettering is, not in squares. + final Sprite fuel; + + /// Yellow, heavy, outlined in black so it reads over the depot's red and + /// white stripes and over the water alike. + static final TextPaint _lettering = TextPaint( + style: TextStyle( + fontSize: 48.0, + fontWeight: FontWeight.w900, + color: const ui.Color(0xFFFFD83A), + letterSpacing: 4.0, + shadows: [ + for (final (dx, dy) in <(double, double)>[ + (-2.5, -2.5), + (2.5, -2.5), + (-2.5, 2.5), + (2.5, 2.5), + ]) + ui.Shadow( + color: const ui.Color(0xFF101010), + offset: ui.Offset(dx, dy), + ), + ], + ), + ); + + final ui.Image _flashSheet; + + /// A blast's flash, played once: a white core swelling into an orange + /// ball and breaking up into red. + SpriteAnimation flash() => SpriteAnimation.fromFrameData( + _flashSheet, + SpriteAnimationData.sequenced( + amount: _flashFrames, + stepTime: 0.07, + textureSize: Vector2.all(_flashSize.toDouble()), + loop: false, + ), + ); + + static const int _bankKinds = 3; + static const int _bankWidth = 12; + static const int _bankHeight = 16; + static const int _flashFrames = 6; + static const int _flashSize = 16; + + static const ui.Color _reed = ui.Color(0xFF4E7F3A); + static const ui.Color _reedLight = ui.Color(0xFF7FAE4E); + static const ui.Color _bulrush = ui.Color(0xFF6B4226); + static const ui.Color _bush = ui.Color(0xFF3C6B34); + static const ui.Color _bushLight = ui.Color(0xFF5E9444); + + static Future _drawBanks() { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + void px(int x, int y, ui.Color colour) => canvas.drawRect( + ui.Rect.fromLTWH(x.toDouble(), y.toDouble(), 1.0, 1.0), + ui.Paint()..color = colour, + ); + // Reeds: blades of different heights, leaning a little. + void blade(int at, int x, int tall, int lean, ui.Color colour) { + for (var y = 0; y < tall; y++) { + px(at + x + (y * lean ~/ 8), _bankHeight - 1 - y, colour); + } + } + + for (final (x, tall, lean) in <(int, int, int)>[ + (2, 12, -1), + (4, 15, 0), + (6, 11, 1), + (8, 14, 1), + (10, 9, 2), + ]) { + blade(0, x, tall, lean, x.isEven ? _reed : _reedLight); + } + // Reeds with a bulrush on the tallest. + const at = _bankWidth; + for (final (x, tall, lean) in <(int, int, int)>[ + (1, 10, 0), + (3, 13, -1), + (5, 15, 0), + (8, 12, 1), + (10, 10, 1), + ]) { + blade(at, x, tall, lean, x.isOdd ? _reed : _reedLight); + } + for (var y = 1; y < 5; y++) { + px(at + 5, y, _bulrush); + px(at + 6, y, _bulrush); + } + // A round bush, lighter on top. + const bush = _bankWidth * 2; + for (var y = 0; y < 9; y++) { + for (var x = 0; x < _bankWidth; x++) { + final dx = x - 5.5; + final dy = y - 5.0; + if (dx * dx + dy * dy * 1.6 <= 30.0) { + px(bush + x, _bankHeight - 9 + y, y < 4 ? _bushLight : _bush); + } + } + } + return recorder.endRecording().toImage( + _bankWidth * _bankKinds, + _bankHeight, + ); + } + + static Future _drawFlash() { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + const colours = [ + ui.Color(0xFFFFFFF0), + ui.Color(0xFFFFE27A), + ui.Color(0xFFFFA23A), + ui.Color(0xFFE8552A), + ui.Color(0xFF9E2B1E), + ]; + for (var frame = 0; frame < _flashFrames; frame++) { + final left = frame * _flashSize; + final reach = 3.5 + frame * 1.0; + for (var y = 0; y < _flashSize; y++) { + for (var x = 0; x < _flashSize; x++) { + final dx = x - 7.5; + final dy = y - 7.5; + final r = dx * dx + dy * dy; + if (r > reach * reach) { + continue; + } + // Hollow as it breaks up: the late frames lose their middle and + // every other square of their rim. + if (frame >= 4 && r < (reach - 3.0) * (reach - 3.0)) { + continue; + } + if (frame == 5 && (x + y).isOdd) { + continue; + } + final band = ((r / (reach * reach)) * 3.0).floor() + frame ~/ 2; + canvas.drawRect( + ui.Rect.fromLTWH((left + x).toDouble(), y.toDouble(), 1.0, 1.0), + ui.Paint()..color = colours[band.clamp(0, colours.length - 1)], + ); + } + } + } + return recorder.endRecording().toImage( + _flashSize * _flashFrames, + _flashSize, + ); + } +} diff --git a/examples/games/river_sortie/lib/src/staging.dart b/examples/games/river_sortie/lib/src/staging.dart new file mode 100644 index 00000000000..fc2cec273c4 --- /dev/null +++ b/examples/games/river_sortie/lib/src/staging.dart @@ -0,0 +1,329 @@ +part of 'river_game.dart'; + +/// The one place that turns an empty [RiverGame] into a river and puts the +/// jet on it: the sun and the jet once, and then, every time a run starts or +/// the jet flies on, the stretches around it with their bridges and targets. +/// +/// **An extension rather than a second class**, the way the other demo +/// games keep theirs: the river is code, not a level document, and this is +/// the only code that says what is on it. Being in the same library, it +/// reaches the game's private state as a method would. +extension RiverGameStaging on RiverGame { + /// Opens the river once the 3D device is: the sun, the jet at the start, + /// and the stretches it can see. Called once, from `buildScene`. + void build(GraphicsDevice device, Scene scene) { + _device = device; + _scene = scene; + _kit = _Kit(device); + _shots = InstancedMeshNode( + _kit.shot, + _kit.glow, + capacity: 8, + name: 'shots', + ); + scene.add(_shots); + blasts = Particles3dComponent( + system: ParticleSystem(capacity: 512), + plane: RiverGame.river, + ); + soot = Particles3dComponent( + system: ParticleSystem(capacity: 128), + plane: RiverGame.river, + ); + addAll([blasts, soot]); + wardrobe = ModelWardrobe( + device: device, + scene: scene, + looks: Craft.looks, + ); + scene + ..ambientIntensity = 0.7 + ..add( + LightNode(name: 'sun', intensity: 2.4) + ..setLocalForward(Vector3(-0.35, -1.0, -0.45)), + ); + + jet = JetComponent( + node: SceneNode(name: 'jet'), + scene: scene, + ); + jet.visual.add( + MeshNode(_kit.playerJet, _kit.painted, name: 'jet primitive'), + ); + wardrobe.dress(jet.visual, Craft.player); + add(jet); + chase = ChaseCamera( + camera: camera3d, + target: jet, + offset: Vector3(0.0, 11.0, 11.0), + // The water level ahead of the jet, whatever height it flies at. + lookOffset: Vector3(0.0, -flightHeight, -9.0), + followAcross: 0.35, + lookAcross: 0.5, + )..advance(0.0); + // After everything that moves the jet, so it follows this frame's move. + add(ChaseCameraComponent(chase)); + built = true; + _restart(); + } + + /// Puts the jet at the start of the checkpoint's stretch, with the river + /// around it built fresh: what was shot there is back, as it was. + void _restart() { + _stretches.clear(); + blasts.system.clear(); + soot.system.clear(); + for (final leftover in children.where( + (child) => child is ShotComponent || child is EnemyShotComponent, + )) { + leftover.removeFromParent(); + } + final start = course.section(run.checkpoint).start + 8.0; + jet + ..position.setValues(course.rowAt(start).center, -start) + ..show(); + speed = 0.0; + refuelling = false; + // A fresh run starts with three jets in reserve after the last one + // ended with none; that is not a jet earned, and says nothing. + _reserveHeard = run.reserve; + phase = Phase.ready; + // The panel names the level while the jet waits; flying announces the + // next one as it is reached. + _announced = stage.index; + banner = null; + _ensureStretches(); + } + + /// Starts the run on level [index] of [campaign], counting from zero, as + /// if every level before it had been flown: for looking at a later level + /// without playing up to it. + void startOnLevel(int index) { + run = RunState()..checkpoint = firstSectionOf(index); + if (built) { + _restart(); + } + } + + /// Builds the stretches from a little behind the jet to as far ahead as + /// the camera sees, and lets go of the ones it has left behind. + void _ensureStretches() => _stretches.cover( + course.sectionIndexAt(distance - 25.0), + course.sectionIndexAt(distance + 160.0), + ); + + _Stretch _buildStretch(int index) { + final section = course.section(index); + final valleyGeometry = DeviceMesh.upload( + _device, + valleyMesh(section), + keepSourceData: false, + ); + final valley = MeshNode( + valleyGeometry, + _kit.painted, + name: 'valley $index', + ); + final water = MeshNode(_kit.water, _kit.waterMaterial, name: 'water $index') + ..setPosition(0.0, 0.0, -(section.start + sectionLength / 2.0)); + _scene + ..add(valley) + ..add(water); + + BridgeComponent? bridge; + DeviceMesh? bridgeGeometry; + DeviceMesh? shieldGeometry; + if (section.hasBridge) { + final row = section.rowAt(section.bridgeAt); + final span = row.half * 2.0 + 2.6; + bridgeGeometry = DeviceMesh.upload(_device, bridgeHalfMesh(span / 2.0)); + shieldGeometry = DeviceMesh.upload(_device, shieldMesh(span)); + final shield = MeshNode(shieldGeometry, _kit.shield, name: 'shield') + ..setPosition(0.0, deckHeight, 0.0) + ..visible = false; + // Each half hangs from its own bank end; the right one is the left + // one turned round to reach back towards the middle. + final left = SceneNode(name: 'bridge $index left') + ..setPosition(-span / 2.0, deckHeight, 0.0) + ..add(MeshNode(bridgeGeometry, _kit.painted)); + final right = SceneNode(name: 'bridge $index right') + ..setPosition(span / 2.0, deckHeight, 0.0) + ..add( + MeshNode(bridgeGeometry, _kit.painted) + ..setRotation(_facing(0.0, -1.0)), + ); + bridge = BridgeComponent( + section: index, + span: span, + left: left, + right: right, + shield: shield, + node: SceneNode(name: 'bridge $index') + ..add(left) + ..add(right) + ..add(shield), + scene: _scene, + position: Vector2(row.center, -section.bridgeAt), + // The bridge's own span and shield go with it. + owns: [bridgeGeometry, shieldGeometry], + ); + add(bridge); + } + + final targets = [ + for (final plan in section.targets) _targetFor(plan), + ]; + addAll(targets); + return _Stretch( + index: index, + valley: valley, + water: water, + geometry: [valleyGeometry], + bridge: bridge, + targets: targets, + reeds: _reedsAlong(index), + ); + } + + /// Reeds and bushes along both banks of stretch [index], added to the game; + /// none until the sprites are drawn. + /// + /// **The same every time it is flown into**: placed by a random of the + /// stretch's own, so a stretch dropped behind the jet and built again on + /// a restart has its reeds where they were. Every one shares the atlas's + /// texture, material and cards, and the renderer draws the ones of a kind + /// as one. + List _reedsAlong(int index) { + if (sprites == null) { + return []; + } + final section = course.section(index); + final random = GameRandom(index * 7919 + 17); + final reeds = [ + for (var at = 3.0; at < sectionLength; at += 6.0) + for (final side in const [-1.0, 1.0]) + if (random.nextDouble() < 0.6) + ?_reedAt( + section, + section.start + at + random.nextDouble() * 3.0, + side, + random, + ), + ]; + addAll(reeds); + return reeds; + } + + SpriteBillboardComponent? _reedAt( + Section section, + double distance, + double side, + GameRandom random, + ) { + final drawn = sprites!; + // Nothing on the road to the bridge. + if ((distance - section.bridgeAt).abs() < 4.0) { + return null; + } + final row = course.rowAt(distance); + final x = + (side < 0.0 ? row.left : row.right) + + side * (0.4 + random.nextDouble() * 0.8); + if (!row.isLand(x)) { + return null; + } + return SpriteBillboardComponent( + sprite: drawn.banks[random.nextInt(drawn.banks.length)], + atlas: atlas, + device: _device, + scene: _scene, + plane: RiverGame.river, + cardHeight: 1.1 + random.nextDouble() * 0.6, + position: Vector2(x, -distance), + elevation: landHeight - 0.05, + ); + } + + void _dropStretch(int index, _Stretch stretch) { + stretch.valley.removeFromParent(); + stretch.water.removeFromParent(); + for (final component in [ + ...stretch.targets, + ...stretch.reeds, + ?stretch.bridge, + ]) { + if (component.parent != null) { + component.removeFromParent(); + } + if (component is TargetComponent) { + wardrobe.forget(component.visual); + } + } + // Frames already sent may still be drawing the valley; the renderer + // gives its buffers back once none can be. The bridge owns its own and + // lets them go the same way when it is removed. + stretch.geometry.forEach(_release); + } + + TargetComponent _targetFor(TargetPlan plan) { + final craft = switch (plan.kind) { + TargetKind.tanker => + plan.distance.floor().isEven ? Craft.tankerA : Craft.tankerB, + TargetKind.helicopter => Craft.helicopter, + TargetKind.jet => Craft.enemyJet, + TargetKind.depot => null, + }; + final target = TargetComponent( + plan: plan, + node: SceneNode(name: plan.kind.name), + channel: + course.rowAt(plan.distance).channelAt(plan.x) ?? (plan.x, plan.x), + scene: _scene, + ); + final visual = target.visual; + switch (plan.kind) { + case TargetKind.tanker: + visual.add(MeshNode(_kit.tanker, _kit.painted)); + case TargetKind.helicopter: + final rotor = MeshNode(_kit.rotor, _kit.painted) + ..setPosition(0.0, 0.53, 0.2); + target.rotor = rotor; + visual + ..add(MeshNode(_kit.helicopter, _kit.painted)) + ..add(rotor); + case TargetKind.jet: + visual.add(MeshNode(_kit.enemyJet, _kit.painted)); + case TargetKind.depot: + visual.add(MeshNode(_kit.depot, _kit.painted)); + signDepot(target); + } + if (craft != null) { + wardrobe.dress(visual, craft); + } + return target; + } + + /// Stands a FUEL sign on the near side of [depot], once the sprites are + /// drawn: a child of the depot's, so it goes up with it. + void signDepot(TargetComponent depot) { + final fuel = sprites?.fuel; + if (fuel == null || depot.down) { + return; + } + depot.add( + SpriteBillboardComponent( + sprite: fuel, + smooth: true, + atlas: atlas, + device: _device, + scene: _scene, + plane: RiverGame.river, + cardHeight: 0.6, + // Flame's children stand in their parent's box, from its corner: the + // middle of the depot across, and just past its near end. + position: Vector2(depot.size.x / 2.0, depot.size.y + 0.1), + elevation: 0.35, + ), + ); + } +} diff --git a/examples/games/river_sortie/pubspec.yaml b/examples/games/river_sortie/pubspec.yaml new file mode 100644 index 00000000000..e0e6f1acee6 --- /dev/null +++ b/examples/games/river_sortie/pubspec.yaml @@ -0,0 +1,66 @@ +name: river_sortie +resolution: workspace +description: A jet up a river that never ends, a Flame game drawn in 3D through flame_flutter3d. +publish_to: 'none' +version: 0.1.0 + +environment: + sdk: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" + +dependencies: + # Flame owns the game: the component tree, the hitboxes and their + # callbacks, the keyboard, the on-screen stick and the HUD. + flame: ^2.0.0-dev.0 + + # The bridge: the 3D layer under Flame's, one clock, and a component whose + # Flame position is written into a scene node every frame. + flame_flutter3d: ^0.9.0-dev.0 + + flutter: + sdk: flutter + + # The scene graph, the meshes and the renderer the 3D layer draws with. + flutter3d: ^0.8.3 + + # The sound: a scene of voices, a silent backend for the tests and until + # the player's first input, SoLoud behind it after that. + flutter3d_audio_core: ^0.8.0 + + # `Bindings`, the key table `FlameInputBridge` translates through. + flutter3d_game: ^0.8.1 + + # The effects a blast is thrown with: emitters, gravity, colour and size + # over a particle's life. + flutter3d_particles: ^0.8.1 + + # `InputState`, `GameAction`, and `GameRandom`, the seeded generator the + # river is laid out with, so every run flies the same river. + flutter3d_sim: ^0.8.1 + + vector_math: ^2.2.0 + +dev_dependencies: + flame_lint: ^1.4.4-dev.0 + + # Flame's own harness, so a test can mount the game and step it. + flame_test: ^3.0.0-dev.0 + + # A device with no GPU behind it, for the frame test. + flutter3d_cpu: ^0.8.0 + + # A fake device that records what is handed back to it, for the release + # test. + flutter3d_hardware: ^0.8.0 + + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true + # The craft, and the one texture the Kenney ships share, beside them where + # their glTF looks for it. Who made each and under what licence is in + # `assets/models/LICENSES.md`. + assets: + - assets/models/ + - assets/models/Textures/ diff --git a/examples/games/river_sortie/test/billboards_test.dart b/examples/games/river_sortie/test/billboards_test.dart new file mode 100644 index 00000000000..63fe215f8f1 --- /dev/null +++ b/examples/games/river_sortie/test/billboards_test.dart @@ -0,0 +1,214 @@ +/// The river's Flame sprites standing in the scene: reeds along the banks, +/// the same each time a stretch is built, and a blast's flash that plays +/// once and goes. +library; + +import 'package:flame_flutter3d/flame_flutter3d.dart' + show SpriteBillboardComponent; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' + show CameraNode, PerspectiveProjection, RenderView, Renderer; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart' show TargetKind; +import 'package:river_sortie/src/river_game.dart'; +import 'package:vector_math/vector_math.dart' show Vector3, Vector4; + +Iterable _billboards(RiverGame game) => + game.children.whereType(); + +void main() { + testWidgets('reeds stand on the land by the banks, where they stood the ' + 'last time the stretch was built', (tester) async { + // Mutation: place them with an unseeded random, or on the water. + late final RiverGame game; + await tester.runAsync(() async { + game = await initializeGame(() => RiverGame(billboards: true)); + game.open3d(cpuTestDevice(width: 32, height: 24).device); + await game.ready(); + await game.drawSprites(); + await game.ready(); + }); + + final reeds = _billboards(game).toList(); + expect(reeds, isNotEmpty); + for (final reed in reeds) { + final row = game.course.rowAt(-reed.position.y); + expect(row.isLand(reed.position.x), isTrue, reason: 'not on the water'); + final bank = + (reed.position.x - row.left).abs() < + (reed.position.x - row.right).abs() + ? row.left + : row.right; + expect((reed.position.x - bank).abs(), lessThan(1.3), reason: 'by it'); + } + + final first = <(double, double)>{ + for (final reed in reeds) (reed.position.x, reed.position.y), + }; + expect(first, hasLength(reeds.length), reason: 'each stood once'); + await tester.runAsync(() async { + game.startOnLevel(0); + await game.ready(); + }); + final again = <(double, double)>{ + for (final reed in _billboards(game)) (reed.position.x, reed.position.y), + }; + expect(again, first); + }); + + testWidgets('every depot says FUEL, on a sign that goes up with it', ( + tester, + ) async { + // Mutation: sign only the depots built after the sprites were drawn, or + // only those standing when they were; stand the sign away from its + // depot; draw its lettering in squares. + late final RiverGame game; + await tester.runAsync(() async { + game = await initializeGame(() => RiverGame(billboards: true)); + game.open3d(cpuTestDevice(width: 32, height: 24).device); + await game.ready(); + await game.drawSprites(); + await game.ready(); + }); + + void expectSigned() { + final depots = game.targets + .where((target) => target.plan.kind == TargetKind.depot) + .toList(); + expect(depots, isNotEmpty); + for (final depot in depots) { + final sign = depot.children + .whereType() + .single; + expect(sign.currentSprite, same(game.sprites!.fuel)); + expect(sign.smooth, isTrue); + expect(sign.absolutePosition.x, closeTo(depot.position.x, 1e-6)); + expect( + sign.absolutePosition.y - depot.position.y, + inInclusiveRange(depot.size.y / 2.0, depot.size.y), + reason: 'on its near side', + ); + } + } + + // Dressed: the depots standing when the sprites were drawn. + expectSigned(); + // Built with it: the depots of a stretch built after. + await tester.runAsync(() async { + game.startOnLevel(0); + await game.ready(); + }); + expectSigned(); + + final depot = game.targets.firstWhere( + (target) => target.plan.kind == TargetKind.depot, + ); + final sign = depot.children.whereType().single; + await tester.runAsync(() async { + game.hitTarget(depot); + for (var i = 0; i < 3; i++) { + game.update(1 / 60); + await game.ready(); + } + }); + expect(sign.isMounted, isFalse, reason: 'gone up with its depot'); + }); + + testWidgets("a blast's flash is drawn: orange where there was none", ( + tester, + ) async { + // Mutation: add the flash component and draw nothing. + final it = cpuTestDevice(width: 160, height: 90); + late final RiverGame game; + final camera = CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.85, far: 400.0), + ); + await tester.runAsync(() async { + game = await initializeGame(() => RiverGame(billboards: true)); + game.open3d(it.device); + await game.ready(); + await game.drawSprites(); + await game.ready(); + }); + camera + ..setPosition(0.0, 12.0, -game.distance + 11.0) + ..lookAt(Vector3(0.0, 0.0, -game.distance - 9.0)); + game.scene.add(camera); + final renderer = Renderer.create( + device: it.device, + fallbackAlbedo: it.albedo, + fallbackNormal: it.normal, + ); + game.attachRenderer(renderer); + + Future orange({double after = 0.0}) async { + game.update(after); + final result = renderer.render( + width: 160, + height: 90, + scene: game.scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.27, 0.48, 0.78, 1)), + ], + ); + final pixels = (await it.device.readPixels(result.frame))!; + final rgba = pixels.buffer.asUint8List(); + var count = 0; + for (var i = 0; i < rgba.length; i += 4) { + final (r, g, b) = (rgba[i], rgba[i + 1], rgba[i + 2]); + if (r > 200 && b < 140 && r > g + 20) { + count++; + } + } + return count; + } + + final before = (await tester.runAsync(orange))!; + await tester.runAsync(() async { + game.blasts.system.clear(); + game.fireball(Vector3(0.0, 2.0, -game.distance - 6.0), size: 1.5); + game.blasts.system.clear(); + await game.ready(); + }); + // Midway through the flash: its orange ball, not its first white core. + final during = (await tester.runAsync(() => orange(after: 0.15)))!; + expect(during, greaterThan(before + 40), reason: 'the flash, drawn'); + }); + + testWidgets("a blast's flash plays where it happened, and goes", ( + tester, + ) async { + late final RiverGame game; + await tester.runAsync(() async { + game = await initializeGame(() => RiverGame(billboards: true)); + game.open3d(cpuTestDevice(width: 32, height: 24).device); + await game.ready(); + await game.drawSprites(); + await game.ready(); + }); + final before = _billboards(game).length; + + await tester.runAsync(() async { + game.fireball(Vector3(2.0, 1.0, -30.0)); + await game.ready(); + }); + final flash = _billboards(game).firstWhere((b) => b.ticker != null); + expect(_billboards(game).length, before + 1); + expect(flash.position.x, closeTo(2.0, 1e-6)); + expect(flash.position.y, closeTo(-30.0, 1e-6)); + expect( + flash.elevation + flash.cardHeight / 2.0, + closeTo(1.0, 1e-6), + reason: 'the flash is centred on the blast', + ); + + await tester.runAsync(() async { + for (var i = 0; i < 30; i++) { + game.update(1 / 60); + await game.ready(); + } + }); + expect(flash.isMounted, isFalse, reason: 'played once, and gone'); + }); +} diff --git a/examples/games/river_sortie/test/course_test.dart b/examples/games/river_sortie/test/course_test.dart new file mode 100644 index 00000000000..4bbe7c43dab --- /dev/null +++ b/examples/games/river_sortie/test/course_test.dart @@ -0,0 +1,136 @@ +/// The river as the course lays it out, with nothing drawn and no game +/// running: the part of River Sortie that decides whether it can be flown. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; + +/// The first dozen stretches, which is further than a good run gets. +const int _sections = 12; + +void main() { + test('the same seed lays out the same river', () { + final a = Course(); + final b = Course(); + for (var d = 0.0; d < sectionLength * 3; d += 7.0) { + expect(a.rowAt(d).center, b.rowAt(d).center); + expect(a.rowAt(d).half, b.rowAt(d).half); + expect(a.rowAt(d).island, b.rowAt(d).island); + } + expect( + a.section(2).targets.map((t) => (t.kind, t.distance, t.x)), + b.section(2).targets.map((t) => (t.kind, t.distance, t.x)), + ); + }); + + test('another seed lays out another river', () { + final a = Course(); + final b = Course(seed: 7); + var differs = false; + for (var d = 20.0; d < sectionLength; d += 5.0) { + differs |= a.rowAt(d).center != b.rowAt(d).center; + } + expect(differs, isTrue); + }); + + test('every stretch meets the next narrow and on the middle line', () { + // Generated one at a time, with no knowledge of each other: this is + // the only thing that makes them join. + final course = Course(); + for (var i = -1; i < _sections; i++) { + final section = course.section(i); + for (final d in [section.start, section.end - 0.001]) { + final row = course.rowAt(d); + expect(row.center, closeTo(0.0, 1e-6), reason: 'section $i at $d'); + expect(row.half, closeTo(narrowHalf, 1e-3), reason: 'section $i'); + expect(row.hasIsland, isFalse, reason: 'section $i'); + } + } + }); + + test('a tanker or a helicopter that moves has water to move across', () { + final course = Course(); + var movers = 0; + for (var i = 0; i < _sections; i++) { + for (final target in course.section(i).targets) { + if (target.kind == TargetKind.jet || target.speed == 0.0) { + continue; + } + movers++; + final (from, to) = course.rowAt(target.distance).channelAt(target.x)!; + expect( + to - from - 2.0 * target.kind.halfLength, + greaterThanOrEqualTo(3.0), + reason: '${target.kind} at ${target.distance}', + ); + } + } + expect(movers, greaterThan(0)); + }); + + test('there is always a channel a jet fits through', () { + final course = Course(); + for (var d = -sectionLength; d < sectionLength * _sections; d += 0.5) { + final row = course.rowAt(d); + final widest = row.channels + .map((c) => c.$2 - c.$1) + .reduce((a, b) => a > b ? a : b); + expect(widest, greaterThan(3.0), reason: 'at $d'); + expect(row.left, greaterThanOrEqualTo(-riverReach - 1e-9)); + expect(row.right, lessThanOrEqualTo(riverReach + 1e-9)); + } + }); + + test('an island still on the bed is water to fly over', () { + const low = RiverRow(center: 0.0, half: 10.0, island: 0.1); + expect(low.dryIsland, 0.0); + expect(low.hasIsland, isFalse); + expect(low.isWater(0.0, halfWidth: 0.75), isTrue); + + const high = RiverRow(center: 0.0, half: 10.0, island: 3.0); + expect(high.dryIsland, closeTo(3.0, 0.1)); + expect(high.isWater(0.0), isFalse); + expect(high.isWater(-6.0, halfWidth: 0.75), isTrue); + expect(high.isLand(0.0, margin: 1.0), isTrue); + }); + + test('everything that floats is laid out on the water', () { + final course = Course(); + for (var i = 0; i < _sections; i++) { + for (final target in course.section(i).targets) { + if (target.kind == TargetKind.jet) { + continue; + } + expect( + course + .rowAt(target.distance) + .isWater(target.x, halfWidth: target.kind.halfLength), + isTrue, + reason: '${target.kind} at ${target.distance}', + ); + } + } + }); + + test('trees and houses stand on the land', () { + final course = Course(); + for (var i = -1; i < 4; i++) { + final section = course.section(i); + expect(section.scenery, isNotEmpty); + for (final plant in section.scenery) { + expect( + section.rowAt(plant.distance).isWater(plant.x), + isFalse, + reason: '${plant.kind} at ${plant.distance}, ${plant.x}', + ); + } + } + }); + + test('the stretch behind the start is calm: no targets, no bridge', () { + final section = Course().section(-1); + expect(section.targets, isEmpty); + expect(section.hasBridge, isFalse); + expect(Course().section(0).hasBridge, isTrue); + }); +} diff --git a/examples/games/river_sortie/test/frame_test.dart b/examples/games/river_sortie/test/frame_test.dart new file mode 100644 index 00000000000..a33419a62a0 --- /dev/null +++ b/examples/games/river_sortie/test/frame_test.dart @@ -0,0 +1,157 @@ +/// The river, drawn: a frame through the game's own camera on a CPU device, +/// with no widget tree and no `GameWidget`, the way +/// `site/content/reference/testing.md` draws one frame of a game. +library; + +import 'dart:typed_data'; + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/models.dart'; +import 'package:river_sortie/src/river_game.dart'; +import 'package:vector_math/vector_math.dart' hide Plane; + +const int _width = 160; +const int _height = 90; + +/// The game's world and a frame of it, from where `RiverScreen` puts its +/// camera: behind the jet and above it, looking up the river. [stage] runs +/// once the game has its renderer, before the frame is drawn. +Future<({Uint8List rgba, int drawCalls})> _frame({ + void Function(RiverGame game)? stage, +}) async { + final it = cpuTestDevice(width: _width, height: _height); + final game = await initializeGame(RiverGame.new); + final scene = Scene(); + game.open3d(it.device, scene: scene); + await game.ready(); + + final camera = + CameraNode( + projection: const PerspectiveProjection( + fovYRadians: 0.85, + far: 400.0, + ), + ) + ..setPosition(0.0, flightHeight + 11.0, -game.distance + 11.0) + ..lookAt(Vector3(0.0, 0.0, -game.distance - 9.0)); + scene.add(camera); + + final renderer = Renderer.create( + device: it.device, + fallbackAlbedo: it.albedo, + fallbackNormal: it.normal, + ); + game.attachRenderer(renderer); + stage?.call(game); + final result = renderer.render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.27, 0.48, 0.78, 1.0)), + ], + ); + final pixels = await it.device.readPixels(result.frame); + return (rgba: pixels!.buffer.asUint8List(), drawCalls: result.drawCalls); +} + +void main() { + test('the valley, the water and the jet are all drawn', () async { + final (:rgba, :drawCalls) = await _frame(); + expect(drawCalls, greaterThan(3)); + + // Grass is green-dominant and water blue-dominant; a frame with both + // has the land and the river where the camera expects them, and not + // everything at the origin or the wrong colour. + var grass = 0; + var water = 0; + for (var i = 0; i < rgba.length; i += 4) { + final (r, g, b) = (rgba[i], rgba[i + 1], rgba[i + 2]); + if (g > r + 20 && g > b + 20) { + grass++; + } + if (b > r + 30 && b > g + 10) { + water++; + } + } + const pixels = _width * _height; + expect(grass, greaterThan(pixels ~/ 10), reason: 'too little land'); + expect(water, greaterThan(pixels ~/ 20), reason: 'too little river'); + }); + + test('a fireball is drawn, through the particle pool', () async { + // The same few frames of flight either way, so the one difference + // between the two pictures is the blast, its shards out of one point. + Future<({Uint8List rgba, int drawCalls})> after({required bool blast}) => + _frame( + stage: (game) { + if (blast) { + game.fireball( + Vector3(game.jet.position.x, 1.5, -game.distance - 6.0), + size: 1.6, + ); + } + for (var i = 0; i < 6; i++) { + game.update(1 / 60); + } + }, + ); + + final calm = await after(blast: false); + final blast = await after(blast: true); + expect(blast.drawCalls, calm.drawCalls + 1, reason: 'one draw for all'); + // Additive: every pixel a shard covers is brighter than without it. + var lit = 0; + for (var i = 0; i < calm.rgba.length; i += 4) { + if (blast.rgba[i] > calm.rgba[i] + 40) { + lit++; + } + } + expect(lit, greaterThan(20)); + }); + + test('smoke is drawn darkening what is behind it', () async { + Future<({Uint8List rgba, int drawCalls})> after({required bool smoke}) => + _frame( + stage: (game) { + if (smoke) { + // Several puffs, so the darkened patch is more than a pixel + // or two at this size. + for (var i = 0; i < 6; i++) { + game.smoke( + Vector3( + game.jet.position.x - 1.5 + i * 0.6, + 1.0, + -game.distance - 6.0, + ), + ); + } + } + for (var i = 0; i < 20; i++) { + game.update(1 / 60); + } + }, + ); + + final clear = await after(smoke: false); + final smoky = await after(smoke: true); + expect(smoky.drawCalls, clear.drawCalls + 1, reason: 'one draw for all'); + var darker = 0; + var brighter = 0; + for (var i = 0; i < clear.rgba.length; i += 4) { + final before = clear.rgba[i] + clear.rgba[i + 1] + clear.rgba[i + 2]; + final now = smoky.rgba[i] + smoky.rgba[i + 1] + smoky.rgba[i + 2]; + if (now < before - 25) { + darker++; + } + if (now > before + 30) { + brighter++; + } + } + expect(darker, greaterThan(20)); + expect(brighter, 0, reason: 'smoke takes light away and adds none'); + }); +} diff --git a/examples/games/river_sortie/test/game_test.dart b/examples/games/river_sortie/test/game_test.dart new file mode 100644 index 00000000000..eb04261f471 --- /dev/null +++ b/examples/games/river_sortie/test/game_test.dart @@ -0,0 +1,438 @@ +/// The game itself: a real `FlameGame`, loaded and mounted the way Flame's +/// own test harness does it, its world built on a CPU device, and stepped by +/// calling `update` sixty times a simulated second. Every hit below is +/// Flame's collision detection finding two hitboxes overlapping. +library; + +import 'dart:math' as math; + +import 'package:flame/collisions.dart' show ShapeHitbox; +import 'package:flame/components.dart' show TextComponent, Vector2; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show GameAction; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/levels.dart'; +import 'package:river_sortie/src/models.dart' show flightHeight; +import 'package:river_sortie/src/river_game.dart'; +import 'package:river_sortie/src/rules.dart'; + +Future _newGame() async { + final game = await initializeGame(RiverGame.new); + game.open3d(cpuTestDevice(width: 32, height: 24).device); + await game.ready(); + return game; +} + +/// Steps [game] [steps] sixtieths of a second, letting Flame add and remove +/// whatever the step queued before the next one. +Future _run(RiverGame game, int steps) async { + for (var i = 0; i < steps; i++) { + game.update(1 / 60); + await game.ready(); + } +} + +/// Puts the jet at [x], [distance] and in the air, the stretches around it +/// built. +Future _flyFrom(RiverGame game, double x, double distance) async { + game.jet.position.setValues(x, -distance); + game.phase = Phase.flying; + await _run(game, 1); +} + +/// Whether a jet at [x] flies from [from] to [to] without touching a bank. +bool _clear(Course course, double x, double from, double to) { + for (var d = from; d <= to; d += 0.25) { + if (!course.rowAt(d).isWater(x, halfWidth: RiverGame.wingReach + 0.2)) { + return false; + } + } + return true; +} + +void main() { + test('the jet waits on the water until the trigger', () async { + final game = await _newGame(); + final start = game.distance; + await _run(game, 60); + expect(game.phase, Phase.ready); + expect(game.distance, start); + + game.input.press(RiverGame.fire); + await _run(game, 60); + expect(game.phase, Phase.flying); + expect(game.distance, greaterThan(start + 5.0)); + }); + + test( + 'Flame moves the jet and the bridge carries it into the scene', + () async { + final game = await _newGame(); + game.input.press(GameAction.moveForward); + await _run(game, 45); + + final node = game.jet.node.readPosition(); + expect(node.z, closeTo(-game.distance, 1e-6)); + expect(node.x, closeTo(game.jet.position.x, 1e-6)); + expect(node.y, closeTo(flightHeight, 1e-6)); + }, + ); + + test('flying onto the bank loses a jet, and the next starts on the ' + 'water', () async { + final game = await _newGame(); + game.input + ..press(RiverGame.fire) + ..press(GameAction.moveLeft); + for (var i = 0; i < 300 && game.phase != Phase.crashed; i++) { + await _run(game, 1); + } + expect(game.phase, Phase.crashed); + expect(game.lastCrash, Crash.bank); + + game.input + ..release(RiverGame.fire) + ..release(GameAction.moveLeft); + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + expect(game.phase, Phase.ready); + expect(game.run.reserve, RunState.startingReserve - 1); + expect( + game.course.rowAt(game.distance).isWater(game.jet.position.x), + isTrue, + ); + }); + + test('a shot brings a target down, and it scores and counts', () async { + final game = await _newGame(); + // A still target the jet has a clear run at from twenty metres short, + // and not a depot, whose blast could take a neighbour and score twice. + final target = game.targets.firstWhere( + (t) => + t.plan.kind != TargetKind.jet && + t.plan.kind != TargetKind.depot && + t.plan.speed == 0.0 && + _clear( + game.course, + t.plan.x, + t.plan.distance - 20.0, + t.plan.distance - 2.0, + ), + ); + await _flyFrom(game, target.plan.x, target.plan.distance - 20.0); + game.input.press(RiverGame.fire); + for (var i = 0; i < 90 && !target.down; i++) { + await _run(game, 1); + } + expect(target.down, isTrue); + // At least: shots still in the air when it went down may find more. + expect(game.run.score, greaterThanOrEqualTo(target.plan.kind.points)); + expect(game.run.tally[target.plan.kind], greaterThanOrEqualTo(1)); + }); + + test('a target going down puts its points over it on the screen, ' + 'and they rise and go', () async { + final game = await _newGame(); + final target = game.targets.firstWhere( + (t) => t.plan.kind != TargetKind.depot, + ); + final at = target.scenePosition; + // The game's own camera, put straight behind and above the target. + game.camera3d + ..setPosition(at.x, at.y + 10.0, at.z + 10.0) + ..lookAt(at); + game.hitTarget(target); + await game.ready(); + + Iterable popups() => game.camera.viewport.children + .whereType() + .where((text) => text.text == '+${target.plan.kind.points}'); + final popup = popups().single; + // Looked at from straight behind and above: the middle of the screen. + expect(popup.position.x, closeTo(game.size.x / 2, 1.0)); + expect(popup.position.y, closeTo(game.size.y / 2, 1.0)); + + await _run(game, 30); + expect(popup.position.y, lessThan(game.size.y / 2 - 10.0)); + await _run(game, 40); + expect(popups(), isEmpty); + }); + + test('a tanker hit lists and sinks, and is no longer solid', () async { + final game = await _newGame(); + final tanker = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.tanker, + ); + game.hitTarget(tanker); + await _run(game, 1); + expect(tanker.children.whereType(), isEmpty); + + await _run(game, 60); + expect(tanker.isMounted, isTrue, reason: 'still going under'); + expect(tanker.elevation, lessThan(-0.3)); + await _run(game, 120); + expect(tanker.isMounted, isFalse); + }); + + test('a helicopter hit spins down into the river', () async { + final game = await _newGame(); + final helicopter = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.helicopter, + ); + game.hitTarget(helicopter); + await _run(game, 20); + expect(helicopter.elevation, lessThan(flightHeight - 0.2)); + await _run(game, 40); + expect(helicopter.isMounted, isFalse); + }); + + test('a depot going up takes its neighbours with it', () async { + final game = await _newGame(); + final depot = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.depot, + ); + final neighbour = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.tanker, + ); + final far = game.targets.lastWhere((t) => t != depot && t != neighbour); + neighbour.position.setFrom(depot.position + Vector2(2.5, 0.0)); + + game.hitTarget(depot); + expect(neighbour.down, isTrue); + expect(far.down, isFalse); + expect(game.run.score, TargetKind.depot.points + TargetKind.tanker.points); + }); + + test('the last bridge of a level stands until the task is done, and ' + 'falling finishes the level', () async { + final game = await _newGame(); + game.startOnLevel(1); + final stage = stageOf(firstSectionOf(1)); + expect(stage.level.task[TargetKind.tanker], greaterThan(0)); + final last = game.course.section(stage.last); + final center = last.rowAt(last.bridgeAt).center; + + // Task not done: the shots spark off it, and the jet flies into it. + await _flyFrom(game, center, last.bridgeAt - 12.0); + final shielded = game.bridges.firstWhere((b) => b.section == stage.last); + expect(game.shielded(shielded), isTrue); + game.input.press(RiverGame.fire); + await _run(game, 60); + expect(shielded.down, isFalse); + expect(shielded.shield.visible, isTrue); + expect(game.lastCrash, Crash.collision); + expect(game.banner, contains('TANKERS')); + + // Task done: the same bridge falls, and the level pays its bonus. + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + game.run.tally[TargetKind.tanker] = stage.level.task[TargetKind.tanker]!; + final before = game.run.score; + await _flyFrom(game, center, last.bridgeAt - 12.0); + final open = game.bridges.firstWhere((b) => b.section == stage.last); + expect(game.shielded(open), isFalse); + await _run(game, 1); + expect(open.shield.visible, isFalse); + for (var i = 0; i < 60 && !open.down; i++) { + await _run(game, 1); + } + expect(open.down, isTrue); + expect( + game.run.score - before, + greaterThanOrEqualTo(500 + stage.level.bonus), + ); + expect(game.run.tally, isEmpty); + expect(game.run.checkpoint, stage.last + 1); + expect(game.banner, contains('LEVEL COMPLETE')); + }); + + test('a gunner helicopter fires at the jet, and its bullet brings the jet ' + 'down', () async { + final game = await _newGame(); + game.startOnLevel(2); + final stage = stageOf(firstSectionOf(2)); + final gunner = + [ + for (var i = stage.first; i <= stage.last; i++) + ...game.course.section(i).targets, + ].firstWhere( + (plan) => + plan.gunner && + _clear( + game.course, + plan.x, + plan.distance - 31.0, + plan.distance - 29.0, + ), + ); + + await _flyFrom(game, gunner.x, gunner.distance - 30.0); + game.speed = 0.0; + var fired = false; + for (var i = 0; i < 120 && !fired; i++) { + // Held in place: only the helicopter's aim is under test here. + game.jet.position.y = -(gunner.distance - 30.0); + await _run(game, 1); + fired = game.children.whereType().isNotEmpty; + } + expect(fired, isTrue); + + for (var i = 0; i < 120 && game.phase == Phase.flying; i++) { + game.jet.position.y = -(gunner.distance - 30.0); + await _run(game, 1); + } + expect(game.lastCrash, Crash.collision); + }); + + test('a depot shot from right over it takes the jet too', () async { + final game = await _newGame(); + final depot = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.depot, + ); + await _flyFrom(game, depot.plan.x, depot.plan.distance - 0.5); + game.hitTarget(depot); + expect(game.phase, Phase.crashed); + expect(game.lastCrash, Crash.collision); + }); + + test('an intact bridge stops the jet', () async { + final game = await _newGame(); + final section = game.course.section(0); + final center = section.rowAt(section.bridgeAt).center; + await _flyFrom(game, center, section.bridgeAt - 6.0); + await _run(game, 40); + expect(game.phase, Phase.crashed); + expect(game.lastCrash, Crash.collision); + }); + + test('a bridge shot down lets the jet through, and the next jet starts ' + 'past it', () async { + final game = await _newGame(); + final section = game.course.section(0); + final center = section.rowAt(section.bridgeAt).center; + final bridge = game.bridges.firstWhere((b) => b.section == 0); + await _flyFrom(game, center, section.bridgeAt - 12.0); + + game.input.press(RiverGame.fire); + for (var i = 0; i < 300 && game.distance < section.end + 5.0; i++) { + await _run(game, 1); + } + expect(bridge.down, isTrue); + expect(game.phase, Phase.flying); + expect(game.distance, greaterThan(section.end)); + expect(game.run.checkpoint, 1); + expect(game.run.score, greaterThanOrEqualTo(500)); + + game.run.fuel = 0.0; + await _run(game, 1); + expect(game.lastCrash, Crash.fuel); + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + expect(game.phase, Phase.ready); + expect(game.course.sectionIndexAt(game.distance), 1); + }); + + test('flying over a depot fills the tank', () async { + final game = await _newGame(); + final depot = game.targets.firstWhere( + (t) => + t.plan.kind == TargetKind.depot && + _clear( + game.course, + t.plan.x, + t.plan.distance - 6.0, + t.plan.distance + 3.0, + ), + ); + await _flyFrom(game, depot.plan.x, depot.plan.distance - 6.0); + game.run.fuel = 0.5; + await _run(game, 45); + expect(game.phase, Phase.flying); + expect(game.run.fuel, greaterThan(0.55)); + }); + + test('a moving target waits for the jet to come close', () async { + final game = await _newGame(); + final mover = game.targets.firstWhere( + (t) => + t.plan.speed > 0.0 && + t.plan.kind != TargetKind.jet && + t.plan.distance - game.distance > RiverGame.wakeRange + 10.0, + ); + await _run(game, 60); + expect(mover.position.x, closeTo(mover.plan.x, 1e-4)); + + // Still on the water, only nearer: it is distance that wakes a target, + // not the jet being in the air. + game.jet.position.y = -(mover.plan.distance - RiverGame.wakeRange + 5.0); + // The furthest it got rather than where it ended: a mover that meets a + // bank turns back, and a second later can be where it started. + var furthest = 0.0; + for (var i = 0; i < 60; i++) { + await _run(game, 1); + furthest = math.max(furthest, (mover.position.x - mover.plan.x).abs()); + } + expect(mover.awake, isTrue); + expect(furthest, greaterThan(0.5)); + }); + + test("every model loads and takes its primitive's place", () async { + final game = await _newGame(); + // The files on disk: an isolate in a test has no app bundle to read. + await game.dressWithModels(source: FileAssetSource.new); + + bool wearsModel(SceneNode visual) => + visual.children.length == 1 && + (visual.children.single.name ?? '').endsWith('model'); + expect(wearsModel(game.jet.visual), isTrue); + for (final target in game.targets) { + expect( + wearsModel(target.visual), + target.plan.kind != TargetKind.depot, + reason: '${target.plan.kind} at ${target.plan.distance}', + ); + } + }); + + test( + 'the last jet lost ends the run, and the trigger starts another', + () async { + final game = await _newGame(); + game.run.reserve = 0; + game.input.press(RiverGame.fire); + await _run(game, 1); + game.run.fuel = 0.0; + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + expect(game.phase, Phase.over); + + game.input + ..release(RiverGame.fire) + ..press(RiverGame.fire); + await _run(game, 1); + expect(game.phase, Phase.ready); + expect(game.run.reserve, RunState.startingReserve); + expect(game.run.score, 0); + }, + ); + + test('a second of flight covers the same river at any frame rate', () async { + // Flown in frames, the jet's distance and its throttle depended on how + // long each frame was. In fixed steps they cannot. + // + // Mutation: fly in Flame's frames rather than in the game's steps. + Future flown(double frame) async { + final game = await _newGame(); + game.input.press(GameAction.moveForward); + final frames = (1.0 / frame).round(); + for (var i = 0; i < frames; i++) { + game.update(frame); + await game.ready(); + } + return game.distance; + } + + final slow = await flown(1 / 30); + expect(slow, greaterThan(0.0)); + expect(await flown(1 / 120), closeTo(slow, 1e-6)); + }); +} diff --git a/examples/games/river_sortie/test/levels_test.dart b/examples/games/river_sortie/test/levels_test.dart new file mode 100644 index 00000000000..778816963f4 --- /dev/null +++ b/examples/games/river_sortie/test/levels_test.dart @@ -0,0 +1,81 @@ +/// The campaign against the river it lays out: that the levels follow each +/// other bridge by bridge, and that every task can be done with what its +/// level puts on the water. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/levels.dart'; + +void main() { + test('the levels follow each other, a bridge each', () { + var section = 0; + for (var i = 0; i < campaign.length - 1; i++) { + final stage = stageOf(section); + expect(stage.index, i); + expect(stage.first, section); + expect(firstSectionOf(i), section); + expect(stageOf(stage.last).index, i); + section = stage.last + 1; + } + // The last level never ends. + expect(stageOf(section).index, campaign.length - 1); + expect(stageOf(section + 500).index, campaign.length - 1); + // The calm water behind the start is the first level's. + expect(stageOf(-1).index, 0); + }); + + test('every task can be done with what its level puts on the river', () { + final course = Course(); + for (var i = 0; i < campaign.length - 1; i++) { + final stage = stageOf(firstSectionOf(i)); + final plans = [ + for (var s = stage.first; s <= stage.last; s++) + ...course.section(s).targets, + ]; + for (final MapEntry(key: kind, value: wanted) + in stage.level.task.entries) { + final there = plans.where((plan) => plan.kind == kind).length; + // With room to miss: a task that needs every last one is a level + // nobody finishes. + expect( + there, + greaterThanOrEqualTo(wanted + (wanted + 1) ~/ 2), + reason: '${stage.level.name} wants $wanted ${kind.name}, has $there', + ); + } + } + }); + + test('the first level has no jets, and later ones have gunners', () { + final course = Course(); + final first = stageOf(0); + for (var s = first.first; s <= first.last; s++) { + expect( + course.section(s).targets.where((t) => t.kind == TargetKind.jet), + isEmpty, + ); + expect(course.section(s).targets.where((t) => t.gunner), isEmpty); + } + final rotors = stageOf(firstSectionOf(2)); + expect( + [ + for (var s = rotors.first; s <= rotors.last; s++) + ...course.section(s).targets, + ].where((t) => t.gunner), + isNotEmpty, + ); + }); + + test('a mix picks each kind in its share', () { + const mix = Mix(tanker: 1.0, helicopter: 1.0, depot: 1.0, jet: 1.0); + final counts = {}; + for (var i = 0; i < 400; i++) { + final kind = mix.kindFor(i / 400); + counts[kind] = (counts[kind] ?? 0) + 1; + } + for (final kind in TargetKind.values) { + expect(counts[kind], 100, reason: kind.name); + } + }); +} diff --git a/examples/games/river_sortie/test/release_test.dart b/examples/games/river_sortie/test/release_test.dart new file mode 100644 index 00000000000..c59e3c81c29 --- /dev/null +++ b/examples/games/river_sortie/test/release_test.dart @@ -0,0 +1,49 @@ +/// A stretch of river the jet has left behind gives its buffers back through +/// the renderer, after the frames that may still be drawing it, and not at +/// once. On a fake device, which records what it is handed back. +library; + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/river_game.dart'; + +void main() { + test( + 'a stretch left behind is released after the frames in flight', + () async { + final device = FakeBackend(); + final scene = Scene(); + final camera = CameraNode(); + scene.add(camera); + final renderer = Renderer.create(device: device); + final game = await initializeGame(RiverGame.new); + game + ..open3d(device, scene: scene) + ..attachRenderer(renderer); + await game.ready(); + + void frame() => renderer.render( + width: 16, + height: 12, + scene: scene, + views: [RenderView(camera: camera)], + ); + + frame(); + // Two stretches on, far enough that the one behind the start is dropped. + game.jet.position.y = -(sectionLength * 2.0 + 10.0); + game.phase = Phase.flying; + game.update(1 / 60); + await game.ready(); + expect(device.releasedGeometry, isEmpty, reason: 'released at once'); + + frame(); + frame(); + frame(); + expect(device.releasedGeometry, isNotEmpty); + }, + ); +} diff --git a/examples/games/river_sortie/test/rules_test.dart b/examples/games/river_sortie/test/rules_test.dart new file mode 100644 index 00000000000..b3ee0403fa0 --- /dev/null +++ b/examples/games/river_sortie/test/rules_test.dart @@ -0,0 +1,58 @@ +/// The rules of a run on their own: points, jets, fuel and the checkpoint. +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/rules.dart'; + +void main() { + test('the targets are worth what they always were', () { + expect(TargetKind.tanker.points, 30); + expect(TargetKind.helicopter.points, 60); + expect(TargetKind.depot.points, 80); + expect(TargetKind.jet.points, 100); + }); + + test('every ten thousand points puts another jet in reserve', () { + final run = RunState()..award(9990); + expect(run.reserve, RunState.startingReserve); + run.award(30); + expect(run.reserve, RunState.startingReserve + 1); + // One award that crosses two thresholds pays for both. + run.award(20000); + expect(run.reserve, RunState.startingReserve + 3); + }); + + test('a full tank lasts its time, and a depot fills it', () { + final run = RunState(); + for (var i = 0; i < 60 * (RunState.tankSeconds ~/ 2); i++) { + run.burn(1 / 60); + } + expect(run.fuel, closeTo(0.5, 0.01)); + expect(run.outOfFuel, isFalse); + + run.refuel(RunState.refillSeconds); + expect(run.fuel, 1.0); + + run.burn(RunState.tankSeconds + 1.0); + expect(run.fuel, 0.0); + expect(run.outOfFuel, isTrue); + }); + + test('the checkpoint only ever moves up the river', () { + final run = RunState()..bridgeDown(2); + expect(run.checkpoint, 3); + run.bridgeDown(0); + expect(run.checkpoint, 3); + }); + + test('a lost jet is replaced from reserve until there is none', () { + final run = RunState()..fuel = 0.2; + for (var i = 0; i < RunState.startingReserve; i++) { + expect(run.nextJet(), isTrue); + expect(run.fuel, 1.0); + } + expect(run.reserve, 0); + expect(run.nextJet(), isFalse); + }); +} diff --git a/examples/games/river_sortie/test/sound_test.dart b/examples/games/river_sortie/test/sound_test.dart new file mode 100644 index 00000000000..32c83eb64d9 --- /dev/null +++ b/examples/games/river_sortie/test/sound_test.dart @@ -0,0 +1,181 @@ +/// What the game says, heard through `flutter3d_audio_core`'s silent backend, +/// which records every voice it is asked for and plays none of them. +library; + +import 'package:flame_test/flame_test.dart'; +import 'package:flutter3d_audio_core/flutter3d_audio_core.dart'; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show GameAction; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/src/course.dart'; +import 'package:river_sortie/src/river_game.dart'; + +/// A game whose speakers are a silent backend that records every voice, +/// opened as the first take-off would open them. +Future<(RiverGame, SilentBackend)> _newGame() async { + final ears = SilentBackend(); + final game = await initializeGame( + () => RiverGame( + speakers: () async => + (scene: AudioScene(backend: ears), close: () async {}), + ), + ); + game.open3d(cpuTestDevice(width: 32, height: 24).device); + await game.sound.open(); + await game.ready(); + return (game, ears); +} + +Future _run(RiverGame game, int steps) async { + for (var i = 0; i < steps; i++) { + game.update(1 / 60); + await game.ready(); + } +} + +Iterable _playing(SilentBackend ears, SoundDef sound) => + ears.live.where((voice) => voice.asset == sound.asset); + +bool _heard(SilentBackend ears, SoundDef sound) => + ears.started.any((voice) => voice.asset == sound.asset); + +void main() { + test('the bank names every file the generator writes, once each', () { + expect(Sounds.all.length, 11); + expect(Sounds.all.assets.toSet(), hasLength(11)); + }); + + test('the engine drones while the jet flies, and climbs with the ' + 'throttle', () async { + final (game, ears) = await _newGame(); + await _run(game, 10); + expect(_playing(ears, Sounds.engine), isEmpty, reason: 'still waiting'); + + game.input.press(GameAction.moveBack); + await _run(game, 60); + final slow = _playing(ears, Sounds.engine).single.rate; + + game.input + ..release(GameAction.moveBack) + ..press(GameAction.moveForward); + await _run(game, 30); + expect(_playing(ears, Sounds.engine).single.rate, greaterThan(slow)); + }); + + test( + 'the first take-off asks for the speakers, and only the first', + () async { + final (game, _) = await _newGame(); + var asked = 0; + game.onFirstFlight = () => asked++; + game.input.press(RiverGame.fire); + await _run(game, 5); + expect(asked, 1); + + game.run.fuel = 0.0; + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + // The next jet waits for a fresh press, as the first one did. + game.input + ..release(RiverGame.fire) + ..press(RiverGame.fire); + await _run(game, 5); + expect(game.phase, Phase.flying); + expect(asked, 1); + }, + ); + + test('a shot, a hit and a crash each make their sound, and a crash ' + 'silences the engine', () async { + final (game, ears) = await _newGame(); + game.input.press(RiverGame.fire); + await _run(game, 20); + expect(_heard(ears, Sounds.shot), isTrue); + + game.hitTarget( + game.targets.firstWhere((t) => t.plan.kind == TargetKind.tanker), + ); + await _run(game, 1); + expect(_heard(ears, Sounds.boom), isTrue); + + game.run.fuel = 0.0; + await _run(game, 2); + expect(_heard(ears, Sounds.crash), isTrue); + expect(_playing(ears, Sounds.engine), isEmpty); + }); + + test('a new run after the last jet is not an extra jet', () async { + final (game, ears) = await _newGame(); + game.run.reserve = 0; + game.input.press(RiverGame.fire); + await _run(game, 1); + game.run.fuel = 0.0; + await _run(game, (RiverGame.crashPause * 60).ceil() + 2); + expect(game.phase, Phase.over); + + game.input + ..release(RiverGame.fire) + ..press(RiverGame.fire); + await _run(game, 3); + expect(game.run.reserve, 3); + expect(_heard(ears, Sounds.extraJet), isFalse); + + // Earned, it is heard. + game.run.award(10000); + await _run(game, 1); + expect(_heard(ears, Sounds.extraJet), isTrue); + }); + + test('the low-fuel alarm sounds below a quarter of a tank and stops over ' + 'a depot', () async { + final (game, ears) = await _newGame(); + game.input.press(GameAction.moveForward); + await _run(game, 2); + game.run.fuel = 0.2; + await _run(game, 2); + expect(_playing(ears, Sounds.lowFuel), hasLength(1)); + + final depot = game.targets.firstWhere( + (t) => t.plan.kind == TargetKind.depot, + ); + game.jet.position.setFrom(depot.position); + // Four steps, not one: put there by hand twenty metres at a time, the + // jet is only found over the depot on the third step after. Flame's + // broadphase catches up with a jump over a few frames; flying never + // makes one. + await _run(game, 4); + expect(game.refuelling, isTrue); + expect(_playing(ears, Sounds.refuel), hasLength(1)); + expect(_playing(ears, Sounds.lowFuel), isEmpty); + }); + + test( + 'a blast up the river is quieter than one near, and on its own side', + () async { + // Mutation: play the river's sounds at the listener, flat. + final (game, ears) = await _newGame(); + await _run(game, 2); + final byDistance = game.targets.toList() + ..sort((a, b) => a.plan.distance.compareTo(b.plan.distance)); + final near = byDistance.first; + final far = byDistance.last; + expect(far.plan.distance - near.plan.distance, greaterThan(60.0)); + + game.hitTarget(near); + await _run(game, 1); + final nearVoice = ears.started.lastWhere( + (v) => v.asset == Sounds.boom.asset || v.asset == Sounds.bigBoom.asset, + ); + game.hitTarget(far); + await _run(game, 1); + final farVoice = ears.started.lastWhere( + (v) => v.asset == Sounds.boom.asset || v.asset == Sounds.bigBoom.asset, + ); + expect(farVoice, isNot(same(nearVoice))); + expect(farVoice.gain, lessThan(nearVoice.gain)); + final side = near.plan.x - game.jet.position.x; + if (side.abs() > 2.0) { + expect(nearVoice.pan.sign, side.sign); + } + }, + ); +} diff --git a/examples/games/river_sortie/test/touch_test.dart b/examples/games/river_sortie/test/touch_test.dart new file mode 100644 index 00000000000..2b40ae62371 --- /dev/null +++ b/examples/games/river_sortie/test/touch_test.dart @@ -0,0 +1,78 @@ +/// A phone's controls: Flame's own stick and fire button, feeding the same +/// `InputState` the keys do. +library; + +import 'package:flame/components.dart' show JoystickComponent; +import 'package:flame/input.dart' show HudButtonComponent; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/foundation.dart' show TargetPlatform; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:river_sortie/river_sortie.dart' show hasTouchControls; +import 'package:river_sortie/src/river_game.dart'; + +Future _touchGame() async { + final game = await initializeGame(RiverGame.new); + game + ..open3d(cpuTestDevice(width: 32, height: 24).device) + ..addTouchControls(); + await game.ready(); + return game; +} + +void main() { + test('Android and iOS get the stick and the button; the rest keep the ' + 'keys', () { + expect(hasTouchControls(TargetPlatform.android), isTrue); + expect(hasTouchControls(TargetPlatform.iOS), isTrue); + for (final platform in [ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, + ]) { + expect(hasTouchControls(platform), isFalse, reason: platform.name); + } + }); + + test("the stick and the fire button are in Flame's viewport", () async { + final game = await _touchGame(); + final viewport = game.camera.viewport.children; + expect(viewport.whereType(), hasLength(1)); + expect(viewport.whereType(), hasLength(1)); + expect(game.touch, isTrue); + }); + + test( + 'the fire button takes off and fires; the stick steers and throttles', + () async { + final game = await _touchGame(); + final button = game.camera.viewport.children + .whereType() + .single; + final stick = game.joystick!; + final startX = game.jet.position.x; + + button.onPressed!(); + await Future.value(); + game.update(1 / 60); + await game.ready(); + expect(game.phase, Phase.flying); + + // Right and forward, held for half a second. The stick recomputes its + // own delta from the drag each update, so the test holds it there. + for (var i = 0; i < 30; i++) { + stick.delta.setValues(stick.knobRadius, -stick.knobRadius * 0.9); + game.update(1 / 60); + await game.ready(); + } + expect(game.jet.position.x, greaterThan(startX + 1.0)); + expect(game.speed, greaterThan(RiverGame.cruiseSpeed)); + expect(game.children.whereType(), isNotEmpty); + + button.onReleased!(); + game.update(1 / 60); + expect(game.input.held(RiverGame.fire), isFalse); + }, + ); +} diff --git a/examples/games/river_sortie/web/favicon.png b/examples/games/river_sortie/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..0d61a9ab6842798371bc198433703104fc760df6 GIT binary patch literal 1751 zcmV;|1}OQ7P)9XUk{}XzC~8SnXsJpiKq8eWs1Lk?5c~y+pLi?} zh({g}P{}C|NGK8)iMDD|1TCg1PL!J+H{;fqu^roUJ9Ez78|^bQ#&&GCiOZIj=5qG= z*0!_F%OgqA%uM)mQvh~qWINc{N&e$2nD74dOmFX_x!2%27TfyR z?9;V67!zYwt!ZO895=L7#%O6Ijku1Aqh`WvBsa2Q66VH0Q<{R5pT=oFH=yrJoDmk= z`mnyn#8}O~xS;_X0Hcj#l9NNEA_n_L$oC8p=?K?xa8>hGX(@=TYNf=pB;tsvyH_d4 z_2k|;j)T8VyjBPawl+5O9#GpNLc5eU*wEW!H5wtuk3PZZ+$zJxS>EpPDGu~lQQ*}h z&Medk>mjcVWf(p77>jF59K3alr{WG?7iF&Xrtq!9O$02q^|5)mn}676NzRl;35mG3 z!tk4KAfu22rA6L&;xm*zhcDllqg$n^&X+L#4mS%0GLhk1OEB7-CpWXioxD#d3`sR; z)#C(v+pTuDgeloznS#)UvJt#fT;a3p%Un$dC_FlYG&e%Q)b(kE<1pQw=cJe7wlZA4 zGe_rKnSUF_dOb}laGDVntq52hZLN)LjX*0z_R|(18+e2qtUB1p1rlmBV7ej#W`jRIhKUv^h}Abgeg`QtCWk&yyXZ~rNIlUKF10< z3iFFx3t-t3q+N#~?UUS2-ZSHCKGV28Z$9D zgA>=7ttz}MC@J}SX@$P|drWFg$ALZ$^kj*{n9y1!W_e+4j|k8diA{kev9h|v@x!Mm zezT8vI?~M4LyjKo;XpRc+DM(XZ`j7Bs05vK&tBrwj}G$t+4p&Ms>J&`ru$!pAU0IW zbsAxu6sLx*^?_yObfDuL_Z`7Y9x#E#sZ!0@30o%u{tsYVcmidv=4 z{PYqpEyKyaK?ZM6^UB0D@7C&^F_Onmp1?7ZZ)=C25#cJs4?A;wZD4@Utyj6CW3H(< zsq`|rhY>nfRFvkTpP_%L%)4PkDOMD+DHbBQGJTgJ z-({j6Bc(}7@uku#$0jDZ8pV7NMtH7BBD>RAC0b@_bsl+qWWw{EPYENm<4P%oNE(rC zEYX-|COKizm$w&JLus-mc2#R8nwnhKfE>ngUWp6KbvkI+fUd2 zJv4k`=q10ydrn9>WH8C>_@IXuh}dX7+bBmD@4h$tYhbqhY`eqVYD0I+k`ekLmI78< t*2esJAvIPLO{MN{*{ST4!1;fz{{k`DGRQl1AK3r^002ovPDHLkV1i6HX$}AY literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/web/icons/Icon-192.png b/examples/games/river_sortie/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..78c12af2ded845c4f3c69f3e9240cf6222eeee6b GIT binary patch literal 16446 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 P0770`MXE;9H1vM~ggAnV literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/web/icons/Icon-512.png b/examples/games/river_sortie/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..dd2754a757b2632d53295510812ea03f505b5e65 GIT binary patch literal 45848 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 literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/web/icons/Icon-maskable-192.png b/examples/games/river_sortie/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..78c12af2ded845c4f3c69f3e9240cf6222eeee6b GIT binary patch literal 16446 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 P0770`MXE;9H1vM~ggAnV literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/web/icons/Icon-maskable-512.png b/examples/games/river_sortie/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..dd2754a757b2632d53295510812ea03f505b5e65 GIT binary patch literal 45848 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 literal 0 HcmV?d00001 diff --git a/examples/games/river_sortie/web/index.html b/examples/games/river_sortie/web/index.html new file mode 100644 index 00000000000..b4a5c81dc02 --- /dev/null +++ b/examples/games/river_sortie/web/index.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + River Sortie + + + + + + + + diff --git a/examples/games/river_sortie/web/manifest.json b/examples/games/river_sortie/web/manifest.json new file mode 100644 index 00000000000..9d04a706f71 --- /dev/null +++ b/examples/games/river_sortie/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "River Sortie", + "short_name": "River Sortie", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A jet up a river that never ends, a Flame game drawn in 3D.", + "orientation": "landscape-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} From 26fc12ecdee1eb0ef1bac2ffdd231f3df57863b7 Mon Sep 17 00:00:00 2001 From: Dmitrii Zolotov Date: Thu, 1 Oct 2026 18:32:23 +0300 Subject: [PATCH 4/4] chore: Rerun CI after a transient npm failure in the spell checker