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/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/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 00000000000..7db813ffe7e Binary files /dev/null and b/examples/games/river_sortie/assets/models/Textures/colormap.png differ 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 00000000000..9aad3f0ebef Binary files /dev/null and b/examples/games/river_sortie/assets/models/helicopter.glb differ diff --git a/examples/games/river_sortie/assets/models/jet_enemy.glb b/examples/games/river_sortie/assets/models/jet_enemy.glb new file mode 100644 index 00000000000..6f6893ee7a6 Binary files /dev/null and b/examples/games/river_sortie/assets/models/jet_enemy.glb differ diff --git a/examples/games/river_sortie/assets/models/jet_player.glb b/examples/games/river_sortie/assets/models/jet_player.glb new file mode 100644 index 00000000000..e77da15c223 Binary files /dev/null and b/examples/games/river_sortie/assets/models/jet_player.glb differ 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 00000000000..a6017608b4e Binary files /dev/null and b/examples/games/river_sortie/assets/models/tanker_a.glb differ 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 00000000000..fe9739970c0 Binary files /dev/null and b/examples/games/river_sortie/assets/models/tanker_b.glb differ diff --git a/examples/games/river_sortie/lib/main.dart b/examples/games/river_sortie/lib/main.dart new file mode 100644 index 00000000000..d7f68478bd8 --- /dev/null +++ b/examples/games/river_sortie/lib/main.dart @@ -0,0 +1,4 @@ +import 'package:flutter/widgets.dart'; +import 'package:river_sortie/river_sortie.dart'; + +void main() => 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 00000000000..0d61a9ab684 Binary files /dev/null and b/examples/games/river_sortie/web/favicon.png differ 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 00000000000..78c12af2ded Binary files /dev/null and b/examples/games/river_sortie/web/icons/Icon-192.png differ 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 00000000000..dd2754a757b Binary files /dev/null and b/examples/games/river_sortie/web/icons/Icon-512.png differ 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 00000000000..78c12af2ded Binary files /dev/null and b/examples/games/river_sortie/web/icons/Icon-maskable-192.png differ 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 00000000000..dd2754a757b Binary files /dev/null and b/examples/games/river_sortie/web/icons/Icon-maskable-512.png differ 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" + } + ] +} 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 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), + ); + }, + ); +}