Переглянути джерело

Field installer and engine fixes: fades, camera, scripted moves, name clashes

Installer / decompiler
- Add the FADE, NFADE and FADEW opcodes (screen_fade), SHAKE, MOVA
  (move to entity), ASPED, background areas (BGON/BGOFF/BGCLR) and the
  remaining scroll opcodes.
- Write IFKEY as entity_manager:is_key_on instead of the undefined Key().
- Give entities that share a name in the original data (e.g. a line and
  a model) unique names, so the model is no longer dropped.
- Name the LADER animation from the model instead of a fixed "Climb".
- Set walk and run animations for models that don't call them
  Walk/Run (second and third animation of the model).
- Default animations for models without Idle, temporary variable bank
  reset per field, scale fixes for walkmesh and scripts.

Engine
- Add the ScreenFade renderer and ScreenFadeState rules (types 1-10 and
  NFADE 0/11/12), plus the fade/nfade console commands.
- Scripted walks of non-player characters are not stopped by the walkmesh
  edge; lines can be triggered when a script zeroes the player's solid
  radius (minimum reach).
- IsKeyOn reads FF7 button masks.
- Scripted ladder/lift moves play on their own while the player is locked.
- Instant background scrolls are applied at the end of the frame, so a
  following timed scroll starts from the current camera position.
- Background tile areas and states, shake, scroll to entity, interaction
  target choice, zero-length animation fix.

Tests
- Decompiler cases for the new opcodes, ScreenFadeState, trigger reach
  and walkmesh edge rules.
Iñigo Valentin 4 днів тому
батько
коміт
df6d14f685

+ 2 - 0
src/CMakeLists.txt

@@ -600,6 +600,8 @@ set(VGEARS_SOURCE_FILES
     core/AudioManager.cpp
     core/Background2DAnimation.cpp
     core/Background2D.cpp
+    core/ScreenFade.cpp
+    core/ScreenFadeState.cpp
     core/BattleManager.cpp
     core/CameraManager.cpp
     core/ConfigCmd.cpp

+ 139 - 2
src/core/Background2D.cpp

@@ -19,6 +19,7 @@
 #include <OgreException.h>
 #include <OgreViewport.h>
 #include <OgreTechnique.h>
+#include <algorithm>
 #include "core/Background2D.h"
 #include "core/CameraManager.h"
 #include "core/EntityManager.h"
@@ -121,6 +122,11 @@ void Background2D::Hide(){scene_manager_->removeRenderQueueListener(this);}
 void Background2D::Show(){scene_manager_->addRenderQueueListener(this);}
 
 void Background2D::Update(){
+    if (has_pending_scroll_){
+        has_pending_scroll_ = false;
+        SetScroll(pending_scroll_);
+    }
+    UpdateShake(Timer::getSingleton().GetGameTimeDelta());
     for (unsigned int i = 0; i < animation_played_.size(); ++ i){
         for (unsigned int j = 0; j < animations_.size(); ++ j){
             if (animations_[j]->GetName() == animation_played_[i].name){
@@ -282,6 +288,109 @@ void Background2D::ScriptAreaOff(const int area, const int layer){
     RefreshArea(area);
 }
 
+namespace{
+    // Random factors the original game applies to each bounce of a shake.
+    const float SHAKE_FACTORS[] = {
+      -1.0f, 1.14f, -1.34f, 0.61f, -0.49f, 0.64f, -0.25f, 0.47f, -1.20f, -0.01f, 1.14f, -0.86f,
+      0.81f, -0.58f, 0.68f, -1.31f, 0.44f, -0.91f, 0.03f, -0.97f, 1.25f, -0.76f, 0.48f, -0.99f,
+      1.34f, -0.85f, 0.55f, -0.65f, 0.07f, -0.83f
+    };
+    const size_t SHAKE_FACTOR_COUNT = sizeof(SHAKE_FACTORS) / sizeof(SHAKE_FACTORS[0]);
+
+    // Frames per second the shake times are given in.
+    const float SHAKE_FRAMES_PER_SECOND = 30.0f;
+
+    // Pixels of the original screen.
+    const float ORIGINAL_SCREEN_WIDTH = 320.0f;
+
+    // Quadratic in and out easing.
+    float ShakeEase(const float t){return t < 0.5f ? 2 * t * t : 1 - 2 * (1 - t) * (1 - t);}
+}
+
+void Background2D::ShakeAxis::Configure(const float new_amplitude, const float new_seconds){
+    amplitude = new_amplitude;
+    seconds = std::max(new_seconds, 1.0f / SHAKE_FRAMES_PER_SECOND);
+    stopping = false;
+    if (!running){
+        running = true;
+        StartBounce();
+    }
+}
+
+void Background2D::ShakeAxis::Stop(const float seconds_to_center){
+    if (!running) return;
+    stopping = true;
+    stop_seconds = std::max(seconds_to_center, 1.0f / SHAKE_FRAMES_PER_SECOND);
+}
+
+void Background2D::ShakeAxis::StartBounce(){
+    from = value;
+    elapsed = 0;
+    if (stopping){
+        to = 0;
+        duration = stop_seconds;
+    }
+    else{
+        // Half the amplitude, times the random factor, is the distance in pixels.
+        to = amplitude / 2 * SHAKE_FACTORS[step % SHAKE_FACTOR_COUNT];
+        ++ step;
+        duration = seconds;
+    }
+}
+
+bool Background2D::ShakeAxis::Advance(const float delta){
+    if (!running) return false;
+    const float old_value = value;
+    elapsed += delta;
+    while (elapsed >= duration){
+        elapsed -= duration;
+        value = to;
+        if (stopping && to == 0){
+            // Back in the center: the shake is over.
+            *this = ShakeAxis();
+            return old_value != 0;
+        }
+        StartBounce();
+    }
+    value = from + (to - from) * ShakeEase(elapsed / duration);
+    return value != old_value;
+}
+
+void Background2D::UpdateShake(const float delta){
+    if (!shake_x_.running && !shake_y_.running) return;
+    const bool x_changed = shake_x_.Advance(delta);
+    const bool y_changed = shake_y_.Advance(delta);
+    if (!x_changed && !y_changed) return;
+    const float scale = virtual_screen_size_.x / ORIGINAL_SCREEN_WIDTH;
+    shake_offset_ = Ogre::Vector2(shake_x_.value * scale, shake_y_.value * scale);
+    applyScroll();
+}
+
+void Background2D::ScriptShake(
+  const int type, const int x_amplitude, const int x_frames, const int y_amplitude,
+  const int y_frames
+){
+    LOG_TRIVIAL("Background2d shake type " + std::to_string(type) + ".");
+    const float x_seconds = x_frames / SHAKE_FRAMES_PER_SECOND;
+    const float y_seconds = y_frames / SHAKE_FRAMES_PER_SECOND;
+    switch (type){
+        case 0:
+            {
+                const float seconds = std::max(x_seconds, y_seconds);
+                shake_x_.Stop(seconds);
+                shake_y_.Stop(seconds);
+            }
+            break;
+        case 1: shake_x_.Configure(static_cast<float>(x_amplitude), x_seconds); break;
+        case 2: shake_y_.Configure(static_cast<float>(y_amplitude), y_seconds); break;
+        case 3:
+            shake_x_.Configure(static_cast<float>(x_amplitude), x_seconds);
+            shake_y_.Configure(static_cast<float>(y_amplitude), y_seconds);
+            break;
+        default: LOG_WARNING("Background2d shake type " + std::to_string(type) + " is unknown.");
+    }
+}
+
 void Background2D::ScriptAreaClear(const int area){
     LOG_TRIVIAL("Background2d area " + std::to_string(area) + " cleared.");
     area_states_on_.erase(area);
@@ -296,6 +405,7 @@ void Background2D::Clear(){
     scroll_current_seconds_ = 0;
     scroll_target_entity_ = nullptr;
     scroll_follow_target_ = false;
+    has_pending_scroll_ = false;
     position_ = Ogre::Vector2::ZERO;
     position_real_ = Ogre::Vector2::ZERO;
     offset_ = Ogre::Vector2::ZERO;
@@ -309,6 +419,9 @@ void Background2D::Clear(){
     animation_played_.clear();
     tiles_.clear();
     area_states_on_.clear();
+    shake_x_ = ShakeAxis();
+    shake_y_ = ShakeAxis();
+    shake_offset_ = Ogre::Vector2::ZERO;
     DestroyVertexBuffers();
     CreateVertexBuffers();
 }
@@ -356,6 +469,8 @@ void Background2D::StartScroll(
   const Ogre::Vector2& position, const SCROLL_TYPE type, const float seconds,
   Entity* target_entity, const bool follow_target
 ){
+    // A newer scroll replaces an instant one that has not been applied yet.
+    has_pending_scroll_ = false;
     // Finish the previous scroll first, without following its target.
     scroll_target_entity_ = nullptr;
     scroll_follow_target_ = false;
@@ -379,9 +494,31 @@ void Background2D::ScriptScrollToPosition(
 ){
     LOG_TRIVIAL(
       "Background2d set scroll to position '"
-      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "'."
+      + Ogre::StringConverter::toString(Ogre::Vector2(x, y)) + "', current '"
+      + Ogre::StringConverter::toString(position_) + "', player would be at '"
+      + (EntityManager::getSingleton().ScriptGetPlayerEntity() != nullptr
+        ? Ogre::StringConverter::toString(
+            GetEntityScroll(EntityManager::getSingleton().ScriptGetPlayerEntity())
+          )
+        : Ogre::String("none"))
+      + "', range " + Ogre::StringConverter::toString(range_.getMinimum()) + " "
+      + Ogre::StringConverter::toString(range_.getMaximum()) + "."
     );
     scroll_entity_ = nullptr;
+    if (type == Background2D::NONE || seconds <= 0){
+        // The scripts set a position and then scroll from it to another one, in the same frame.
+        // The camera is only moved at the end of the frame, so if another scroll comes first the
+        // movement starts from where the camera was, and there is no jump.
+        // TODO: Some scenes still show a small camera jump (mds7pb_1, when the NPCs go down the
+        // pinball machine and when Marlene says "Papa"), and the camera stays fixed after them
+        // until the script scrolls to the player. How the original game handles it is not known.
+        scroll_target_entity_ = nullptr;
+        scroll_follow_target_ = false;
+        UnsetScroll();
+        pending_scroll_ = Ogre::Vector2(x, y);
+        has_pending_scroll_ = true;
+        return;
+    }
     StartScroll(Ogre::Vector2(x, y), type, seconds, nullptr, false);
 }
 
@@ -455,7 +592,7 @@ const Ogre::Vector2 Background2D::ClampScroll(const Ogre::Vector2& position) con
 
 void Background2D::applyScroll(){
     if(cv_background2d_manual.GetB() != true){
-        position_real_ = GetScreenScroll();
+        position_real_ = (GetScroll() + shake_offset_) * screen_scale_;
         position_real_.x += offset_.x;
         position_real_.y += offset_.y;
         CameraManager::getSingleton().Set2DScroll(position_real_);

+ 137 - 0
src/core/Background2D.h

@@ -216,6 +216,25 @@ class Background2D : public Ogre::RenderQueueListener{
          */
         void ScriptAreaClear(const int area);
 
+        /**
+         * Shakes the view.
+         *
+         * The shake is added to the scroll, and doesn't change it. Each axis is a chain of random
+         * bounces of the given amplitude, one every given number of frames. Changing the
+         * parameters of a shake that is running takes effect when the current bounce ends.
+         *
+         * @param[in] type 0 stops the shake and brings the view back, 1 shakes the X axis, 2 the
+         * Y axis and 3 both.
+         * @param[in] x_amplitude Amplitude of the bounces in the X axis, in original screen pixels.
+         * @param[in] x_frames Frames between bounces in the X axis.
+         * @param[in] y_amplitude Amplitude of the bounces in the Y axis, in original screen pixels.
+         * @param[in] y_frames Frames between bounces in the Y axis.
+         */
+        void ScriptShake(
+          const int type, const int x_amplitude, const int x_frames, const int y_amplitude,
+          const int y_frames
+        );
+
         /**
          * Stops the current scrolling.
          */
@@ -502,6 +521,96 @@ class Background2D : public Ogre::RenderQueueListener{
          */
         bool IsAreaStateOn(const int area, const int layers) const;
 
+        /**
+         * A chain of random bounces in one axis of the view.
+         */
+        struct ShakeAxis{
+
+            /**
+             * Amplitude of the next bounces, in original screen pixels.
+             */
+            float amplitude = 0;
+
+            /**
+             * Duration of the next bounces, in seconds.
+             */
+            float seconds = 0;
+
+            /**
+             * Whether the axis is moving.
+             */
+            bool running = false;
+
+            /**
+             * Whether the axis is going back to the center to stop.
+             */
+            bool stopping = false;
+
+            /**
+             * Duration of the movement back to the center, in seconds.
+             */
+            float stop_seconds = 0;
+
+            /**
+             * Position at the start of the current bounce.
+             */
+            float from = 0;
+
+            /**
+             * Position at the end of the current bounce.
+             */
+            float to = 0;
+
+            /**
+             * Duration of the current bounce, in seconds.
+             */
+            float duration = 0;
+
+            /**
+             * Time since the start of the current bounce, in seconds.
+             */
+            float elapsed = 0;
+
+            /**
+             * Index of the next random factor.
+             */
+            size_t step = 0;
+
+            /**
+             * Current position, in original screen pixels.
+             */
+            float value = 0;
+
+            /**
+             * Changes the bounces of the axis, and starts it if it is stopped.
+             */
+            void Configure(const float new_amplitude, const float new_seconds);
+
+            /**
+             * Makes the axis go back to the center and stop after the current bounce.
+             */
+            void Stop(const float seconds_to_center);
+
+            /**
+             * Moves the axis forward in time.
+             *
+             * @return True if the position changed.
+             */
+            bool Advance(const float delta);
+
+            /**
+             * Starts the next bounce.
+             */
+            void StartBounce();
+        };
+
+        /**
+         * Moves the shake forward in time, and applies it to the view if it changed.
+         *
+         * @param[in] delta Seconds since the last frame.
+         */
+        void UpdateShake(const float delta);
+
         /**
          * Draws or hides the tiles of an area according to its states.
          *
@@ -638,6 +747,21 @@ class Background2D : public Ogre::RenderQueueListener{
          */
         std::map<int, unsigned int> area_states_on_;
 
+        /**
+         * The shake of the view in the X axis.
+         */
+        ShakeAxis shake_x_;
+
+        /**
+         * The shake of the view in the Y axis.
+         */
+        ShakeAxis shake_y_;
+
+        /**
+         * The shake being applied to the scroll, in virtual screen units.
+         */
+        Ogre::Vector2 shake_offset_ = Ogre::Vector2::ZERO;
+
         /**
          * Alpha blending render operation.
          */
@@ -738,6 +862,19 @@ class Background2D : public Ogre::RenderQueueListener{
          */
         bool scroll_follow_target_;
 
+        /**
+         * Position of an instant scroll that waits for the end of the frame.
+         *
+         * A script that sets a position and scrolls to another one in the same frame must see the
+         * scroll start where the camera was, not at the first position. See ScriptScrollToPosition.
+         */
+        Ogre::Vector2 pending_scroll_;
+
+        /**
+         * Whether there is an instant scroll waiting for the end of the frame.
+         */
+        bool has_pending_scroll_ = false;
+
         /**
          * @todo Document.
          */

+ 42 - 0
src/core/ConfigCmdHandlerCommands.h

@@ -374,6 +374,46 @@ void CmdScreenshot(const Ogre::StringVector& params){
 
 
 
+/**
+ * Starts a screen fade, like the FADE opcode does. It is meant to test the fades by hand.
+ *
+ * @param[in] params Command parameters: the command name, the fade type, red, green, blue and
+ * speed.
+ */
+void CmdFade(const Ogre::StringVector& params){
+    if (params.size() != 6){
+        Console::getSingleton().AddTextToOutput(
+          "Usage: /fade <type 1-10> <red 0-255> <green 0-255> <blue 0-255> <speed>"
+        );
+        return;
+    }
+    EntityManager::getSingleton().GetScreenFade()->ScriptFade(
+      Ogre::StringConverter::parseInt(params[1]), Ogre::StringConverter::parseInt(params[2]),
+      Ogre::StringConverter::parseInt(params[3]), Ogre::StringConverter::parseInt(params[4]),
+      Ogre::StringConverter::parseInt(params[5])
+    );
+}
+
+/**
+ * Starts a screen fade, like the NFADE opcode does. It is meant to test the fades by hand.
+ *
+ * @param[in] params Command parameters: the command name, the fade type (0 clears it, 11 adds
+ * and 12 subtracts), red, green, blue and frames.
+ */
+void CmdNFade(const Ogre::StringVector& params){
+    if (params.size() != 6){
+        Console::getSingleton().AddTextToOutput(
+          "Usage: /nfade <type 0, 11 or 12> <red 0-255> <green 0-255> <blue 0-255> <frames>"
+        );
+        return;
+    }
+    EntityManager::getSingleton().GetScreenFade()->ScriptNFade(
+      Ogre::StringConverter::parseInt(params[1]), Ogre::StringConverter::parseInt(params[2]),
+      Ogre::StringConverter::parseInt(params[3]), Ogre::StringConverter::parseInt(params[4]),
+      Ogre::StringConverter::parseInt(params[5])
+    );
+}
+
 /*
 void CmdViewer(const Ogre::StringVector& params){
     if (params.size() == 2 || params.size() > 3){
@@ -448,6 +488,8 @@ void ConfigCmdHandler::InitCmd(){
     AddCommand("map", "Run game module", "", CmdMap, CmdMapCompletion);
     AddCommand("resolution", "Change resolution", "", CmdResolution, CmdResolutionCompletition);
     AddCommand("screenshot", "Capture current screen content", "", CmdScreenshot, NULL);
+    AddCommand("fade", "Start a screen fade", "<type> <r> <g> <b> <speed>", CmdFade, NULL);
+    AddCommand("nfade", "Start a screen nfade", "<type> <r> <g> <b> <frames>", CmdNFade, NULL);
     //AddCommand(
     //  "viewer", "Run viewer module", "", CmdViewer, CmdViewerCompletion
     //);

+ 14 - 0
src/core/Entity.cpp

@@ -382,6 +382,11 @@ void Entity::SetMoveAutoAnimation(const bool animate){move_auto_animation_ = ani
 
 bool Entity::GetMoveAutoAnimation() const{return move_auto_animation_;}
 
+void Entity::SetMoveAnimations(const char* walk, const char* run){
+    move_animation_walk_ = walk;
+    move_animation_run_ = run;
+}
+
 const Ogre::String& Entity::GetMoveAnimationWalkName() const{return move_animation_walk_;}
 
 const Ogre::String& Entity::GetMoveAnimationRunName() const{return move_animation_run_;}
@@ -610,6 +615,15 @@ Entity::ScriptTurnToEntity(Entity* entity, const TurnDirection turn_direction, c
     LOG_TRIVIAL("Entity '" + name_ + "' turn to entity '" + entity->GetName() + ".");
 }
 
+void Entity::ScriptFaceEntity(Entity* entity){
+    if (entity == nullptr || entity == this){
+        LOG_ERROR("Face entity: Invalid entity pointer (NULL or self).");
+        return;
+    }
+    SetRotation(GetDirectionToEntity(entity));
+    LOG_TRIVIAL("Entity '" + name_ + "' faces entity '" + entity->GetName() + "'.");
+}
+
 int Entity::ScriptTurnSync(){
     ScriptId script = ScriptManager::getSingleton().GetCurrentScriptId();
     LOG_TRIVIAL(

+ 15 - 0
src/core/Entity.h

@@ -597,6 +597,14 @@ class Entity{
          */
         const Ogre::String& GetMoveAnimationWalkName() const;
 
+        /**
+         * Sets the animations played when the entity moves on its own.
+         *
+         * @param[in] walk Name of the walk animation.
+         * @param[in] run Name of the run animation.
+         */
+        void SetMoveAnimations(const char* walk, const char* run);
+
         /**
          * Retrieves the entity's run animation name.
          *
@@ -881,6 +889,13 @@ class Entity{
           const float seconds
         );
 
+        /**
+         * Makes the entity face another entity at once.
+         *
+         * @param[in] entity Entity to face.
+         */
+        void ScriptFaceEntity(Entity* entity);
+
         /**
          * Adds the entity's turn to the sync queue.
          *

+ 53 - 16
src/core/EntityManager.cpp

@@ -15,6 +15,7 @@
 
 #include <iostream>
 #include <cmath>
+#include <limits>
 #include <OgreEntity.h>
 #include <OgreRoot.h>
 #include <OgreViewport.h>
@@ -293,6 +294,7 @@ void EntityManager::UpdateField(){
     player_run_ = false;
     if (background_2d_.GetScrollType() != Background2D::NONE) SetNextScrollStep();
     background_2d_.Update();
+    screen_fade_.Update(Timer::getSingleton().GetGameTimeDelta());
 }
 
 void EntityManager::UpdateBattle(){
@@ -456,6 +458,7 @@ void EntityManager::OnResize(){background_2d_.OnResize();}
 void EntityManager::ClearField(){
     walkmesh_.Clear();
     background_2d_.Clear();
+    screen_fade_.Clear();
     DialogsManager::getSingleton().Clear();
     for (unsigned int i = 0; i < entity_.size(); ++ i){
         ScriptManager::getSingleton().RemoveEntity(ScriptManager::ENTITY, entity_[i]->GetName());
@@ -558,6 +561,8 @@ bool EntityManager::IsWorldPositionBlocked(
 
 Background2D* EntityManager::GetBackground2D(){return &background_2d_;}
 
+ScreenFade* EntityManager::GetScreenFade(){return &screen_fade_;}
+
 void EntityManager::AddEntity(
   const Ogre::String& name, const Ogre::String& file_name,
   const Ogre::Vector3& position, const Ogre::Degree& rotation, int index
@@ -756,16 +761,17 @@ bool EntityManager::IsKeyOn(unsigned int key_code){
     // TODO: Maybe this will interfere with something down the road?
     // Some scene where the player can't move but must press a button?
     if (player_lock_) return false;
-    unsigned int code = key_code;
-    // Translate keycodes to game key codes.
-    // For example 32 is standard for "Enter", but in game is "Circle/Action".
-    // TODO: Finish this list.
-    // TOOD: Get values from config.lua
-    switch (key_code){
-        case 32: code = OIS::KC_SPACE; break;
-    }
-    bool pressed = InputManager::getSingleton().IsButtonPressed(code);
-    return pressed;
+    // The key code of the scripts is a mask of the pad buttons, and the check is true if any of
+    // them is pressed. Translate each to its key. For example 32 is "Circle/Action", the space bar.
+    // TODO: Get values from config.lua
+    static const struct{unsigned int mask; int key;} KEYS[] = {
+      {4, OIS::KC_PGUP}, {8, OIS::KC_PGDOWN}, {32, OIS::KC_SPACE}, {64, OIS::KC_ESCAPE},
+      {4096, OIS::KC_UP}, {8192, OIS::KC_RIGHT}, {16384, OIS::KC_DOWN}, {32768, OIS::KC_LEFT}
+    };
+    for (const auto& entry : KEYS)
+        if ((key_code & entry.mask) && InputManager::getSingleton().IsButtonPressed(entry.key))
+            return true;
+    return false;
 }
 
 bool EntityManager::IsKeyOff(unsigned int key_code){return !IsKeyOn(key_code);}
@@ -845,6 +851,24 @@ bool EntityManager::SetEntityOnWalkmesh(Entity* entity){
     return true;
 }
 
+float EntityManager::TriggerReach(float solid_radius){
+    // The radius an entity has when a script doesn't change it.
+    const float default_radius = 0.21f;
+    return solid_radius < 0.01f ? default_radius : solid_radius;
+}
+
+bool EntityManager::WalkmeshEdgeBlocks(bool is_player, bool scripted_move){
+    return is_player || !scripted_move;
+}
+
+bool EntityManager::IsBetterInteractionTarget(
+  float angle, float distance, float best_angle, float best_distance
+){
+    const float same_angle = 0.001f;
+    if (angle < best_angle - same_angle) return true;
+    return std::fabs(angle - best_angle) <= same_angle && distance < best_distance;
+}
+
 float EntityManager::ScriptedMoveStep(float distance, float stop_distance, float max_step){
     return std::min(max_step, std::max(0.0f, distance - stop_distance));
 }
@@ -1126,6 +1150,7 @@ bool EntityManager::WalkmeshBorderCross(
     }
     int current_triangle = entity->GetMoveTriangleId();
     if (current_triangle == -1) return true;
+    const bool free_walk = !WalkmeshEdgeBlocks(entity == player_entity_, entity->IsScriptedMove());
     Ogre::Vector2 pos = Ogre::Vector2(position.x, position.y);
     for (;;){
         Ogre::Vector3 A3 = walkmesh_.GetA(current_triangle);
@@ -1148,14 +1173,15 @@ bool EntityManager::WalkmeshBorderCross(
         }
         if (next_triangle >= 0){
             bool lock = walkmesh_.IsLocked(next_triangle);
-            if (lock == false){
+            if (lock == false || free_walk){
                 current_triangle = next_triangle;
                 continue;
             }
         }
         position.z = PointElevation(pos, A3, B3, C3);
         entity->SetMoveTriangleId(current_triangle);
-        return true;
+        // A walk of a script goes on over the edge, keeping the last triangle.
+        return !free_walk;
     }
 }
 
@@ -1215,7 +1241,7 @@ void EntityManager::CheckTriggers(Entity* entity, const Ogre::Vector3& position)
 
             // Calculate distance
             float square_dist = SquareDistanceToLine(entity_position, point_a, point_b, proj);
-            float solid_radius = entity->GetSolidRadius();
+            float solid_radius = TriggerReach(entity->GetSolidRadius());
 
             // Calculate direction.
             const Ogre::Degree direction_to_line = GetDirectionToPoint(entity_position, proj);
@@ -1333,6 +1359,7 @@ void EntityManager::CheckEntityInteract(){
     Ogre::Degree angle_pc = player_entity_->GetRotation();
     Entity* entity_to_interact = NULL;
     Ogre::Degree less_angle(90);
+    float best_distance = std::numeric_limits<float>::max();
     Ogre::Real nearest_worldmap_distance = -1.0f;
     for (unsigned int i = 0; i < entity_.size(); ++ i){
         if (entity_[i]->IsTalkable() == false) continue;
@@ -1371,8 +1398,15 @@ void EntityManager::CheckEntityInteract(){
                 Ogre::Degree angle = angle_pc - angle_to_model;
                 angle = (angle < Ogre::Degree(0)) ? -angle : angle;
                 angle = (angle >= Ogre::Degree(180)) ? Ogre::Degree(360) - angle : angle;
-                if (angle < less_angle){
-                    angle = less_angle;
+                // With several entities in range, interact with the one the player faces most.
+                // Nobody 90 degrees or more away from where the player faces is considered.
+                if (
+                  angle < Ogre::Degree(90) && IsBetterInteractionTarget(
+                    angle.valueDegrees(), distance, less_angle.valueDegrees(), best_distance
+                  )
+                ){
+                    less_angle = angle;
+                    best_distance = distance;
                     entity_to_interact = entity_[i];
                 }
             }
@@ -1440,7 +1474,10 @@ void EntityManager::SetNextLinearStep(Entity* entity){
     Ogre::Vector3 end = entity->GetLinearEnd();
     Ogre::Vector3 current = entity->GetPosition();
     float delta = Timer::getSingleton().GetGameTimeDelta();
-    if (entity == player_entity_){
+    // The player climbs a ladder with the directional keys. While a scene has the player locked
+    // the keys have no effect, and the scene makes the player move on its own, as with any other
+    // entity.
+    if (entity == player_entity_ && !player_lock_){
         LinearMovement move = entity->GetLinearMovement();
         if (move == LM_DOWN_TO_UP || move == LM_UP_TO_DOWN){
             if (player_move_.y > 0){ // move up

+ 53 - 0
src/core/EntityManager.h

@@ -22,6 +22,7 @@
 #include "EntityTrigger.h"
 #include "Event.h"
 #include "Manager.h"
+#include "ScreenFade.h"
 #include "Walkmesh.h"
 
 class EntityModel;
@@ -53,6 +54,45 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         static float ScriptedMoveStep(float distance, float stop_distance, float max_step);
 
+        /**
+         * Decides whether an entity is a better target to interact with than the best one so far.
+         *
+         * The one the player faces more directly wins. If the angles are the same, the nearest
+         * one does.
+         *
+         * @param[in] angle Angle in degrees between where the player faces and the candidate.
+         * @param[in] distance Squared distance to the candidate.
+         * @param[in] best_angle Angle of the best candidate so far, or the largest one allowed.
+         * @param[in] best_distance Squared distance to the best candidate so far.
+         * @return True if the candidate is better.
+         */
+        static bool IsBetterInteractionTarget(
+          float angle, float distance, float best_angle, float best_distance
+        );
+
+        /**
+         * Retrieves how close to a line an entity has to be to trigger it.
+         *
+         * It's the solid radius of the entity, unless a script has made it too small, as scripts
+         * do while a scene is playing. Lines couldn't be triggered at all then.
+         *
+         * @param[in] solid_radius Solid radius of the entity.
+         * @return The distance to the line from which it triggers.
+         */
+        static float TriggerReach(float solid_radius);
+
+        /**
+         * Checks if the edge of the walkmesh stops an entity.
+         *
+         * It stops the player and the characters that wander. The characters that a script walks
+         * to a position are not stopped: the original game lets them walk off the walkmesh.
+         *
+         * @param[in] is_player Whether the entity is the player.
+         * @param[in] scripted_move Whether a script asked the entity to walk to a position.
+         * @return True if the edge of the walkmesh blocks the entity.
+         */
+        static bool WalkmeshEdgeBlocks(bool is_player, bool scripted_move);
+
         /**
          * Handles an input event.
          *
@@ -118,6 +158,13 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         Background2D* GetBackground2D();
 
+        /**
+         * Retrieves the coloured fade of the screen.
+         *
+         * @return The screen fade.
+         */
+        ScreenFade* GetScreenFade();
+
         /**
          * Adds an entity to the manager.
          *
@@ -650,6 +697,12 @@ class EntityManager : public Manager, public Ogre::Singleton<EntityManager>{
          */
         Background2D background_2d_;
 
+        /**
+         * The coloured fade of the screen. It has to be created after the background, because the
+         * fade must be drawn over it.
+         */
+        ScreenFade screen_fade_;
+
         /**
          * The entity table name.
          */

+ 4 - 0
src/core/EntityModel.cpp

@@ -75,6 +75,10 @@ void EntityModel::PlayAnimation(
         animation_current_name_ = animation;
         animation_current_ = model_->getAnimationState(animation);
         animation_current_->setLoop((play_type == Entity::PLAY_LOOPED) ? true : false);
+        // Single frame animations have no length. A looping animation without length wraps its
+        // time position dividing by zero, ends up with a time that is not a number, and never
+        // finishes, so any script waiting for it would wait forever.
+        if (animation_current_->getLength() <= 0.0f) animation_current_->setLoop(false);
         animation_current_->setEnabled(true);
         animation_current_->setTimePosition(
           (start == -1) ? animation_current_->getLength() : start

+ 196 - 0
src/core/ScreenFade.cpp

@@ -0,0 +1,196 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <OgreHardwareBufferManager.h>
+#include <OgreMaterialManager.h>
+#include <OgreRoot.h>
+#include <OgreTechnique.h>
+#include "core/CameraManager.h"
+#include "core/EntityManager.h"
+#include "core/Logger.h"
+#include "core/ScreenFade.h"
+
+namespace{
+
+    // The quad is two triangles.
+    const unsigned int QUAD_VERTEX_COUNT = 6;
+
+    // Position (3), colour (4) and texture coordinates (2).
+    const unsigned int QUAD_FLOATS_PER_VERTEX = 9;
+
+    // Resource group of the materials. The background creates its own there as well.
+    const char* const MATERIAL_GROUP = "FIELDS";
+
+    /**
+     * Creates a material that is drawn over everything in the field, without lighting or texture.
+     */
+    Ogre::MaterialPtr CreateMaterial(const Ogre::String& name){
+        Ogre::MaterialPtr material = Ogre::MaterialManager::getSingleton().create(
+          name, MATERIAL_GROUP
+        );
+        Ogre::Pass* pass = material->getTechnique(0)->getPass(0);
+        pass->setVertexColourTracking(Ogre::TVC_AMBIENT);
+        pass->setCullingMode(Ogre::CULL_NONE);
+        pass->setDepthCheckEnabled(false);
+        pass->setDepthWriteEnabled(false);
+        pass->setLightingEnabled(false);
+        // No texture: the fade is a flat colour. A texture that can't be loaded is replaced with a
+        // dark placeholder, which turned every colour black.
+        return material;
+    }
+}
+
+ScreenFade::ScreenFade(){
+    scene_manager_ = Ogre::Root::getSingleton().getSceneManager("Scene");
+    render_system_ = Ogre::Root::getSingletonPtr()->getRenderSystem();
+
+    normal_material_ = CreateMaterial("ScreenFadeNormal");
+    normal_material_->getTechnique(0)->getPass(0)->setSceneBlending(Ogre::SBT_TRANSPARENT_ALPHA);
+
+    add_material_ = CreateMaterial("ScreenFadeAdd");
+    add_material_->getTechnique(0)->getPass(0)->setSceneBlending(Ogre::SBT_ADD);
+
+    // The colour is taken from the field: field - colour, and not colour - field.
+    subtract_material_ = CreateMaterial("ScreenFadeSubtract");
+    Ogre::Pass* pass = subtract_material_->getTechnique(0)->getPass(0);
+    pass->setSceneBlending(Ogre::SBF_ONE, Ogre::SBF_ONE);
+    pass->setSceneBlendingOperation(Ogre::SBO_REVERSE_SUBTRACT);
+
+    render_op_.vertexData = new Ogre::VertexData;
+    render_op_.vertexData->vertexStart = 0;
+    render_op_.vertexData->vertexCount = QUAD_VERTEX_COUNT;
+    Ogre::VertexDeclaration* declaration = render_op_.vertexData->vertexDeclaration;
+    size_t offset = 0;
+    declaration->addElement(0, 0, Ogre::VET_FLOAT3, Ogre::VES_POSITION);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT3);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT4, Ogre::VES_DIFFUSE);
+    offset += Ogre::VertexElement::getTypeSize(Ogre::VET_FLOAT4);
+    declaration->addElement(0, offset, Ogre::VET_FLOAT2, Ogre::VES_TEXTURE_COORDINATES);
+    vertex_buffer_ = Ogre::HardwareBufferManager::getSingletonPtr()->createVertexBuffer(
+      declaration->getVertexSize(0), QUAD_VERTEX_COUNT, Ogre::HardwareBuffer::HBU_DYNAMIC_WRITE_ONLY,
+      false
+    );
+    render_op_.vertexData->vertexBufferBinding->setBinding(0, vertex_buffer_);
+    render_op_.operationType = Ogre::RenderOperation::OT_TRIANGLE_LIST;
+    render_op_.useIndexes = false;
+
+    scene_manager_->addRenderQueueListener(this);
+}
+
+ScreenFade::~ScreenFade(){
+    scene_manager_->removeRenderQueueListener(this);
+    delete render_op_.vertexData;
+}
+
+void ScreenFade::Update(const float delta){
+    if (!state_.Advance(delta)) return;
+    for (const ScriptId& script : waiting_) ScriptManager::getSingleton().ContinueScriptExecution(script);
+    waiting_.clear();
+}
+
+void ScreenFade::Clear(){
+    state_.Reset();
+    draw_logged_ = false;
+    waiting_.clear();
+}
+
+void ScreenFade::ScriptFade(const int type, const int r, const int g, const int b, const int speed){
+    LOG_TRIVIAL(
+      "Screen fade type " + std::to_string(type) + ", colour " + std::to_string(r) + " "
+      + std::to_string(g) + " " + std::to_string(b) + ", speed " + std::to_string(speed) + "."
+    );
+    draw_logged_ = false;
+    if (!state_.StartFade(type, r, g, b, speed))
+        LOG_WARNING("Screen fade type " + std::to_string(type) + " is not known.");
+}
+
+void ScreenFade::ScriptNFade(
+  const int type, const int r, const int g, const int b, const int frames
+){
+    LOG_TRIVIAL(
+      "Screen nfade type " + std::to_string(type) + ", colour " + std::to_string(r) + " "
+      + std::to_string(g) + " " + std::to_string(b) + ", " + std::to_string(frames) + " frames."
+    );
+    draw_logged_ = false;
+    if (!state_.StartNFade(type, r, g, b, frames))
+        LOG_WARNING("Screen nfade type " + std::to_string(type) + " is not known.");
+}
+
+int ScreenFade::ScriptWait(){
+    if (!state_.IsFading()) return 1;
+    waiting_.push_back(ScriptManager::getSingleton().GetCurrentScriptId());
+    return -1;
+}
+
+void ScreenFade::WriteQuad(const FadeColour& colour, const float alpha){
+    // The corners of the screen, in the coordinates the background is drawn in.
+    const float corners[QUAD_VERTEX_COUNT][2] = {
+      {-1, 1}, {1, 1}, {1, -1}, {-1, 1}, {1, -1}, {-1, -1}
+    };
+    float* write_iterator
+      = static_cast<float*>(vertex_buffer_->lock(Ogre::HardwareBuffer::HBL_DISCARD));
+    for (unsigned int i = 0; i < QUAD_VERTEX_COUNT; ++ i){
+        *write_iterator ++ = corners[i][0];
+        *write_iterator ++ = corners[i][1];
+        *write_iterator ++ = 0;
+        *write_iterator ++ = colour.r;
+        *write_iterator ++ = colour.g;
+        *write_iterator ++ = colour.b;
+        *write_iterator ++ = alpha;
+        *write_iterator ++ = 0;
+        *write_iterator ++ = 0;
+    }
+    vertex_buffer_->unlock();
+}
+
+void ScreenFade::renderQueueStarted(
+  Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+){
+    if (queue_group_id != Ogre::RENDER_QUEUE_OVERLAY || !state_.IsVisible()) return;
+    if (!EntityManager::getSingleton().IsFieldModule()) return;
+
+    Ogre::MaterialPtr material;
+    switch (state_.GetBlend()){
+        case ScreenFadeState::BLEND_NORMAL: material = normal_material_; break;
+        case ScreenFadeState::BLEND_ADD: material = add_material_; break;
+        case ScreenFadeState::BLEND_SUBTRACT: material = subtract_material_; break;
+        default: return;
+    }
+    WriteQuad(state_.GetColour(), state_.GetAlpha());
+    if (!draw_logged_){
+        draw_logged_ = true;
+        LOG_TRIVIAL(
+          "Screen fade drawn, blend " + std::to_string(static_cast<int>(state_.GetBlend()))
+          + ", colour " + std::to_string(state_.GetColour().r) + " "
+          + std::to_string(state_.GetColour().g) + " " + std::to_string(state_.GetColour().b)
+          + ", alpha " + std::to_string(state_.GetAlpha()) + "."
+        );
+    }
+
+    // Same setup the background uses: the quad is given in screen coordinates.
+    Ogre::GpuProgramParametersPtr rs_params = render_system_->getFixedFunctionParams(
+      Ogre::TVC_NONE, Ogre::FOG_NONE
+    );
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_WORLD_MATRIX, Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(
+      Ogre::GpuProgramParameters::ACT_PROJECTION_MATRIX, Ogre::Matrix4::IDENTITY
+    );
+    // TODO This is deprecated, but if not done the quad is drawn with the last view matrix.
+    render_system_->_setViewMatrix(Ogre::Matrix4::IDENTITY);
+    rs_params->setConstant(Ogre::GpuProgramParameters::ACT_VIEW_MATRIX, Ogre::Matrix4::IDENTITY);
+    render_system_->applyFixedFunctionParams(rs_params, Ogre::GPV_GLOBAL);
+    scene_manager_->_setPass(material->getTechnique(0)->getPass(0), true, false);
+    render_system_->_render(render_op_);
+}

+ 160 - 0
src/core/ScreenFade.h

@@ -0,0 +1,160 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+#include <vector>
+#include <OgreHardwareVertexBuffer.h>
+#include <OgreMaterial.h>
+#include <OgreRenderOperation.h>
+#include <OgreRenderQueueListener.h>
+#include <OgreRenderSystem.h>
+#include <OgreSceneManager.h>
+#include "ScreenFadeState.h"
+#include "ScriptManager.h"
+
+/**
+ * A coloured fade of the screen, for flashes and fades to a colour.
+ *
+ * The colour is drawn over the field and its models, and below the dialogs and the rest of the
+ * interface. The rules of the fades are in {@see ScreenFadeState}.
+ */
+class ScreenFade : public Ogre::RenderQueueListener{
+
+    public:
+
+        /**
+         * Constructor.
+         */
+        ScreenFade();
+
+        /**
+         * Destructor.
+         */
+        virtual ~ScreenFade();
+
+        /**
+         * Run each frame. Moves the fade forward, and resumes the scripts that wait for its end.
+         *
+         * @param[in] delta Seconds since the last frame.
+         */
+        void Update(const float delta);
+
+        /**
+         * Removes the fade. Run when a field is unloaded.
+         */
+        void Clear();
+
+        /**
+         * Starts a fade like the FADE opcode.
+         *
+         * @param[in] type Fade type, from 1 to 10.
+         * @param[in] r Red component, from 0 to 255.
+         * @param[in] g Green component, from 0 to 255.
+         * @param[in] b Blue component, from 0 to 255.
+         * @param[in] speed Speed of the fade. 8 takes a second.
+         */
+        void ScriptFade(const int type, const int r, const int g, const int b, const int speed);
+
+        /**
+         * Starts a fade like the NFADE opcode.
+         *
+         * @param[in] type Fade type: 0, 11 or 12.
+         * @param[in] r Red component, from 0 to 255.
+         * @param[in] g Green component, from 0 to 255.
+         * @param[in] b Blue component, from 0 to 255.
+         * @param[in] frames Duration of the fade, in frames of 30 per second.
+         */
+        void ScriptNFade(const int type, const int r, const int g, const int b, const int frames);
+
+        /**
+         * Waits for the fade to end, like the FADEW opcode. It continues at once if there is no
+         * fade running.
+         *
+         * @return 1 if the script doesn't have to wait, and -1 if it does.
+         */
+        int ScriptWait();
+
+        /**
+         * Draws the fade, over the field and below the interface.
+         *
+         * It's drawn when the overlay queue starts, which is when the interface is drawn, and not
+         * after the main queue, where it would depend on the order of the listeners and be
+         * covered by the background if the background registered itself again.
+         */
+        void renderQueueStarted(
+          Ogre::uint8 queue_group_id, const Ogre::String& invocation, bool& skip_this_invocation
+        ) override;
+
+    private:
+
+        /**
+         * Writes the colour in the vertices of the screen quad.
+         *
+         * @param[in] colour The colour.
+         * @param[in] alpha The alpha.
+         */
+        void WriteQuad(const FadeColour& colour, const float alpha);
+
+        /**
+         * The rules and the progress of the fade.
+         */
+        ScreenFadeState state_;
+
+        /**
+         * Whether the current fade has been logged as drawn.
+         */
+        bool draw_logged_ = false;
+
+        /**
+         * The scripts that wait for the fade to end.
+         */
+        std::vector<ScriptId> waiting_;
+
+        /**
+         * The scene manager.
+         */
+        Ogre::SceneManager* scene_manager_;
+
+        /**
+         * The render system.
+         */
+        Ogre::RenderSystem* render_system_;
+
+        /**
+         * Material that covers the field.
+         */
+        Ogre::MaterialPtr normal_material_;
+
+        /**
+         * Material that adds to the field.
+         */
+        Ogre::MaterialPtr add_material_;
+
+        /**
+         * Material that subtracts from the field.
+         */
+        Ogre::MaterialPtr subtract_material_;
+
+        /**
+         * Render operation of the screen quad.
+         */
+        Ogre::RenderOperation render_op_;
+
+        /**
+         * Vertex buffer of the screen quad.
+         */
+        Ogre::HardwareVertexBufferSharedPtr vertex_buffer_;
+};

+ 170 - 0
src/core/ScreenFadeState.cpp

@@ -0,0 +1,170 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <algorithm>
+#include "core/ScreenFadeState.h"
+
+namespace{
+
+    // Frames per second the NFADE durations are given in.
+    const float FRAMES_PER_SECOND = 30.0f;
+
+    // The colour of the opcode as it is given.
+    FadeColour Standard(const int r, const int g, const int b){
+        FadeColour colour;
+        colour.r = r / 255.0f;
+        colour.g = g / 255.0f;
+        colour.b = b / 255.0f;
+        return colour;
+    }
+
+    // 255 minus each component of the colour.
+    FadeColour Inverse1(const int r, const int g, const int b){
+        FadeColour colour;
+        colour.r = (255 - r) / 255.0f;
+        colour.g = (255 - g) / 255.0f;
+        colour.b = (255 - b) / 255.0f;
+        return colour;
+    }
+
+    // Four times 255 minus each component of the colour. It can go over full intensity.
+    FadeColour Inverse4(const int r, const int g, const int b){
+        FadeColour colour = Inverse1(r, g, b);
+        colour.r *= 4;
+        colour.g *= 4;
+        colour.b *= 4;
+        return colour;
+    }
+
+    // Black adds or subtracts nothing.
+    FadeColour Black(){return FadeColour();}
+
+    float Clamp(const float value){return std::min(1.0f, std::max(0.0f, value));}
+}
+
+float ScreenFadeState::SpeedToSeconds(const int speed){
+    if (speed <= 0) return 0;
+    return 8.0f / speed;
+}
+
+void ScreenFadeState::Begin(
+  const Blend blend, const FadeColour& from, const FadeColour& to, const float alpha_from,
+  const float alpha_to, const float seconds, const bool hold
+){
+    blend_ = blend;
+    from_ = from;
+    to_ = to;
+    alpha_from_ = alpha_from;
+    alpha_to_ = alpha_to;
+    duration_ = seconds;
+    elapsed_ = 0;
+    hold_ = hold;
+    fading_ = seconds > 0;
+    holding_ = !fading_ && hold;
+    if (!fading_ && !hold) Reset();
+}
+
+bool ScreenFadeState::StartFade(
+  const int type, const int r, const int g, const int b, const int speed
+){
+    const float seconds = SpeedToSeconds(speed);
+    switch (type){
+        case 1:
+            Begin(BLEND_SUBTRACT, Inverse4(r, g, b), Black(), 1, 1, seconds, false);
+            return true;
+        case 2:
+            Begin(BLEND_SUBTRACT, Black(), Inverse4(r, g, b), 1, 1, seconds, true);
+            return true;
+        case 4:
+            // The screen goes black at once and stays that way.
+            Begin(BLEND_NORMAL, Black(), Black(), 1, 1, 0, true);
+            return true;
+        case 5:
+            Begin(BLEND_ADD, Standard(r, g, b), Black(), 1, 1, seconds, false);
+            return true;
+        case 6:
+            Begin(BLEND_NORMAL, Standard(r, g, b), Standard(r, g, b), 0, 1, seconds, true);
+            return true;
+        case 7:
+            Begin(BLEND_SUBTRACT, Inverse1(r, g, b), Inverse1(r, g, b), 1, 1, seconds, false);
+            return true;
+        case 8:
+            Begin(BLEND_SUBTRACT, Inverse1(r, g, b), Inverse1(r, g, b), 1, 1, seconds, true);
+            return true;
+        case 9:
+            Begin(BLEND_ADD, Standard(r, g, b), Standard(r, g, b), 1, 1, seconds, false);
+            return true;
+        case 10:
+            Begin(BLEND_ADD, Standard(r, g, b), Standard(r, g, b), 1, 1, seconds, true);
+            return true;
+        default: return false;
+    }
+}
+
+bool ScreenFadeState::StartNFade(
+  const int type, const int r, const int g, const int b, const int frames
+){
+    const float seconds = frames > 0 ? frames / FRAMES_PER_SECOND : 0;
+    switch (type){
+        case 0:
+            Reset();
+            return true;
+        case 11:
+            Begin(BLEND_ADD, Black(), Standard(r, g, b), 1, 1, seconds, true);
+            return true;
+        case 12:
+            Begin(BLEND_SUBTRACT, Black(), Standard(r, g, b), 1, 1, seconds, true);
+            return true;
+        default: return false;
+    }
+}
+
+bool ScreenFadeState::Advance(const float seconds){
+    if (!fading_) return false;
+    elapsed_ += seconds;
+    if (elapsed_ < duration_) return false;
+    fading_ = false;
+    holding_ = hold_;
+    if (!holding_) Reset();
+    return true;
+}
+
+void ScreenFadeState::Reset(){*this = ScreenFadeState();}
+
+bool ScreenFadeState::IsFading() const{return fading_;}
+
+bool ScreenFadeState::IsVisible() const{return fading_ || holding_;}
+
+ScreenFadeState::Blend ScreenFadeState::GetBlend() const{return IsVisible() ? blend_ : BLEND_NONE;}
+
+FadeColour ScreenFadeState::GetColour() const{
+    FadeColour colour = to_;
+    if (fading_){
+        const float progress = duration_ > 0 ? Clamp(elapsed_ / duration_) : 1.0f;
+        colour.r = from_.r + (to_.r - from_.r) * progress;
+        colour.g = from_.g + (to_.g - from_.g) * progress;
+        colour.b = from_.b + (to_.b - from_.b) * progress;
+    }
+    colour.r = Clamp(colour.r);
+    colour.g = Clamp(colour.g);
+    colour.b = Clamp(colour.b);
+    return colour;
+}
+
+float ScreenFadeState::GetAlpha() const{
+    if (!fading_) return Clamp(alpha_to_);
+    const float progress = duration_ > 0 ? Clamp(elapsed_ / duration_) : 1.0f;
+    return Clamp(alpha_from_ + (alpha_to_ - alpha_from_) * progress);
+}

+ 226 - 0
src/core/ScreenFadeState.h

@@ -0,0 +1,226 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#pragma once
+
+/**
+ * A colour whose components go from 0 (none) to 1 (full intensity).
+ */
+struct FadeColour{
+
+    /**
+     * Red component.
+     */
+    float r = 0;
+
+    /**
+     * Green component.
+     */
+    float g = 0;
+
+    /**
+     * Blue component.
+     */
+    float b = 0;
+};
+
+/**
+ * The rules of the screen fades of the FADE and NFADE opcodes.
+ *
+ * A fade is a linear tween of a colour that is drawn over the field, blended with it. Depending
+ * on the fade type the colour is added to the field, subtracted from it, or mixed with it. Some
+ * fades keep their last colour when the tween ends, and the others go back to showing the field.
+ * A black colour in an additive or subtractive fade changes nothing.
+ *
+ * This class has no dependency on the rendering engine, so the rules can be tested.
+ */
+class ScreenFadeState{
+
+    public:
+
+        /**
+         * How the colour is combined with the field.
+         */
+        enum Blend{
+
+            /**
+             * Nothing is drawn.
+             */
+            BLEND_NONE,
+
+            /**
+             * The colour covers the field in proportion to the alpha.
+             */
+            BLEND_NORMAL,
+
+            /**
+             * The colour is added to the field.
+             */
+            BLEND_ADD,
+
+            /**
+             * The colour is subtracted from the field.
+             */
+            BLEND_SUBTRACT
+        };
+
+        /**
+         * Starts a fade of the FADE opcode, replacing the one that is running.
+         *
+         * @param[in] type Fade type, from 1 to 10. 3 is not used.
+         * @param[in] r Red component, from 0 to 255.
+         * @param[in] g Green component, from 0 to 255.
+         * @param[in] b Blue component, from 0 to 255.
+         * @param[in] speed Speed of the fade. 8 takes a second, and double speed halves the time.
+         * @return False if the type is unknown, and then nothing changes.
+         */
+        bool StartFade(const int type, const int r, const int g, const int b, const int speed);
+
+        /**
+         * Starts a fade of the NFADE opcode, replacing the one that is running.
+         *
+         * @param[in] type Fade type: 0 clears the fade, 11 adds and 12 subtracts.
+         * @param[in] r Red component, from 0 to 255.
+         * @param[in] g Green component, from 0 to 255.
+         * @param[in] b Blue component, from 0 to 255.
+         * @param[in] frames Duration of the fade, in frames of 30 per second.
+         * @return False if the type is unknown, and then nothing changes.
+         */
+        bool StartNFade(const int type, const int r, const int g, const int b, const int frames);
+
+        /**
+         * Moves the fade forward in time.
+         *
+         * @param[in] seconds Time since the last call.
+         * @return True if the tween of the fade ended with this call.
+         */
+        bool Advance(const float seconds);
+
+        /**
+         * Removes any fade, including the colour it holds.
+         */
+        void Reset();
+
+        /**
+         * Checks if a tween is running. A script waiting for the fade waits while it is.
+         *
+         * @return True if the fade is changing.
+         */
+        bool IsFading() const;
+
+        /**
+         * Checks if something has to be drawn: a tween, or the colour it holds.
+         *
+         * @return True if the fade covers the field.
+         */
+        bool IsVisible() const;
+
+        /**
+         * Retrieves how the colour has to be combined with the field.
+         *
+         * @return The blending.
+         */
+        Blend GetBlend() const;
+
+        /**
+         * Retrieves the colour to draw now, with all its components between 0 and 1.
+         *
+         * @return The colour.
+         */
+        FadeColour GetColour() const;
+
+        /**
+         * Retrieves how much the colour covers the field. Only the normal blending uses it.
+         *
+         * @return The alpha, between 0 and 1.
+         */
+        float GetAlpha() const;
+
+        /**
+         * Converts the speed of the FADE opcode to the seconds the fade takes.
+         *
+         * @param[in] speed The speed. 1 is 8 seconds, 2 is 4, 4 is 2 and 8 is 1.
+         * @return The seconds. 0 for a speed that is not positive, meaning instant.
+         */
+        static float SpeedToSeconds(const int speed);
+
+    private:
+
+        /**
+         * Starts a fade.
+         *
+         * @param[in] blend How the colour is combined with the field.
+         * @param[in] from Colour at the start.
+         * @param[in] to Colour at the end.
+         * @param[in] alpha_from Alpha at the start.
+         * @param[in] alpha_to Alpha at the end.
+         * @param[in] seconds Duration. If it is 0 the fade is instant.
+         * @param[in] hold Whether the final colour stays on the screen.
+         */
+        void Begin(
+          const Blend blend, const FadeColour& from, const FadeColour& to, const float alpha_from,
+          const float alpha_to, const float seconds, const bool hold
+        );
+
+        /**
+         * How the colour is combined with the field.
+         */
+        Blend blend_ = BLEND_NONE;
+
+        /**
+         * Colour at the start of the tween.
+         */
+        FadeColour from_;
+
+        /**
+         * Colour at the end of the tween.
+         */
+        FadeColour to_;
+
+        /**
+         * Alpha at the start of the tween.
+         */
+        float alpha_from_ = 1;
+
+        /**
+         * Alpha at the end of the tween.
+         */
+        float alpha_to_ = 1;
+
+        /**
+         * Duration of the tween.
+         */
+        float duration_ = 0;
+
+        /**
+         * Time since the start of the tween.
+         */
+        float elapsed_ = 0;
+
+        /**
+         * Whether the tween is running.
+         */
+        bool fading_ = false;
+
+        /**
+         * Whether the final colour stays on the screen when the tween ends.
+         */
+        bool hold_ = false;
+
+        /**
+         * Whether the final colour is on the screen after the tween.
+         */
+        bool holding_ = false;
+};

+ 13 - 0
src/core/ScriptManagerBinds.h

@@ -135,12 +135,14 @@ void ScriptManager::InitBinds(){
       "set_move_triangle_id", &Entity::SetMoveTriangleId,
       "move_auto_rotation", &Entity::SetMoveAutoRotation,
       "move_auto_animation", &Entity::SetMoveAutoAnimation,
+      "set_move_animations", &Entity::SetMoveAnimations,
       "move_to_position", &Entity::ScriptMoveToPosition,
       "move_to_entity", &Entity::ScriptMoveToEntity,
       "linear_to_position", &Entity::ScriptLinearToPosition,
       "jump_to_position", &Entity::ScriptJumpToPosition,
       "offset_to_position", &Entity::ScriptOffsetToPosition,
       "turn_to_entity", &Entity::ScriptTurnToEntity,
+      "face_entity", &Entity::ScriptFaceEntity,
       "turn_to_direction", &Entity::ScriptTurnToDirection,
       "set_animation_speed", &Entity::ScriptSetAnimationSpeed,
       "play_animation", sol::overload(
@@ -365,9 +367,19 @@ void ScriptManager::InitBinds(){
       "area_on", &Background2D::ScriptAreaOn,
       "area_off", &Background2D::ScriptAreaOff,
       "area_clear", &Background2D::ScriptAreaClear,
+      "shake", &Background2D::ScriptShake,
       "play_animation_looped", &Background2D::ScriptPlayAnimationLooped,
       "play_animation_once", &Background2D::ScriptPlayAnimationOnce
     );
+    // Coloured fades of the screen
+    lua.new_usertype<ScreenFade>(
+      "ScreenFade",
+      "fade", &ScreenFade::ScriptFade,
+      "nfade", &ScreenFade::ScriptNFade
+    );
+    sol::table screen_fade_type = lua["ScreenFade"];
+    BindYielding(screen_fade_type, "wait", &ScreenFade::ScriptWait);
+
     sol::table background_type = lua["Background2D"];
     BindYielding(background_type, "scroll_sync", &Background2D::ScriptScrollSync);
     BindYielding(background_type, "animation_sync", &Background2D::ScriptAnimationSync);
@@ -493,6 +505,7 @@ void ScriptManager::InitBinds(){
     lua["audio_manager"] = AudioManager::getSingletonPtr();
     lua["savemap_manager"] = SavemapHandler::getSingletonPtr();
     lua["background2d"] = EntityManager::getSingletonPtr()->GetBackground2D();
+    lua["screen_fade"] = EntityManager::getSingletonPtr()->GetScreenFade();
     lua["walkmesh"] = EntityManager::getSingletonPtr()->GetWalkmesh();
     lua["text_manager"] = TextHandler::getSingletonPtr();
     lua["dialog"] = DialogsManager::getSingletonPtr();

+ 18 - 0
src/installer/FieldDataInstaller.h

@@ -314,6 +314,24 @@ class FF7FieldScriptFormatter : public BaseFF7FieldScriptFormatter{
             return first;
         }
 
+        /**
+         * Retrieves the animation a model plays when it walks or runs on its own.
+         *
+         * @param[in] char_id ID of the model, as given to the CHAR opcode.
+         * @param[in] index 1 for the walk animation, 2 for the run animation.
+         * @return Name of the animation, or an empty string if the model hasn't got it.
+         */
+        virtual std::string GetMoveAnimationName(int char_id, int index) override{
+            if (char_id < 0 || static_cast<unsigned int>(char_id) >= model_loader_->GetModels().size())
+                return "";
+            const auto& animations = model_loader_->GetModels().at(char_id).animations;
+            if (static_cast<unsigned int>(index) >= animations.size()) return "";
+            Ogre::String base_name;
+            VGears::StringUtil::splitBase(animations[index].name, base_name);
+            VGears::StringUtil::toLowerCase(base_name);
+            return VGears::NameLookup::Animation(base_name);
+        }
+
     private:
 
         /**

+ 2 - 0
src/installer/decompiler/ScriptFormatter.cpp

@@ -23,6 +23,8 @@ std::string ScriptFormatter::GetFriendlyAnimationName(int animation_id, int id){
 
 std::string ScriptFormatter::GetDefaultAnimationName(int char_id){return "";}
 
+std::string ScriptFormatter::GetMoveAnimationName(int char_id, int index){return "";}
+
 std::string ScriptFormatter::GetFriendlyCharName(int char_id){return std::to_string(char_id);}
 
 std::string ScriptFormatter::GetFriendlyFunctionName(const std::string& entity_name, const std::string& function_name){

+ 12 - 0
src/installer/decompiler/ScriptFormatter.h

@@ -68,6 +68,18 @@ class ScriptFormatter{
          */
         virtual std::string GetDefaultAnimationName(int char_id);
 
+        /**
+         * Retrieves the animation a model plays when it walks or runs on its own.
+         *
+         * In the original game the second animation of a model is its walk, and the third its
+         * run. Only some models have those called "Walk" and "Run".
+         *
+         * @param[in] char_id ID of the model, as given to the CHAR opcode.
+         * @param[in] index 1 for the walk animation, 2 for the run animation.
+         * @return Name of the animation, or an empty string if the model hasn't got it.
+         */
+        virtual std::string GetMoveAnimationName(int char_id, int index);
+
         /**
          * Retrieves a friendly name for a character.
          *

+ 8 - 1
src/installer/decompiler/field/FieldDisassembler.cpp

@@ -114,11 +114,18 @@ std::unique_ptr<Function> FieldDisassembler::StartFunction(size_t script_index){
 
 void FieldDisassembler::DoDisassemble(){
     // Loop through the scripts for each entity.
+    std::set<std::string> used_entity_names;
     for (size_t entity_number = 0; entity_number < header_.entity_scripts.size(); entity_number ++){
         std::string original_name = header_.field_entity_names[entity_number].data();
         // If the entity name was blank in the file then use a consistent generated name
         if (original_name.empty()) original_name = "entity_" + std::to_string(entity_number);
-        const std::string entity_name = formatter_.GetFriendlyEntityName(original_name);
+        std::string entity_name = formatter_.GetFriendlyEntityName(original_name);
+        // The original game allows several entities to share a name, such as a line and a model.
+        // Here a name identifies the entity in scripts, the map and the engine, so keep it unique.
+        if (!used_entity_names.insert(entity_name).second){
+            entity_name += "_" + std::to_string(entity_number);
+            used_entity_names.insert(entity_name);
+        }
         // Only parse each script one.
         std::set<uint16> parsed_scripts;
         std::vector<ScriptInfo> script_info;

+ 28 - 35
src/installer/decompiler/field/instruction/FieldCameraInstruction.cpp

@@ -31,7 +31,7 @@ void FieldCameraInstruction::ProcessInst(
     //std::cout << "Camera opcode: " << opcode_ << "\n";
     switch (opcode_){
     case OPCODES::NFADE: ProcessNFADE(code_gen); break;
-    case OPCODES::SHAKE: code_gen->WriteTodo(md.GetEntityName(), "SHAKE"); break;
+    case OPCODES::SHAKE: ProcessSHAKE(code_gen); break;
     case OPCODES::SCRLO: code_gen->WriteTodo(md.GetEntityName(), "SCRLO"); break;
     case OPCODES::SCRLC: ProcessSCRLC(code_gen); break;
     case OPCODES::SCR2D: ProcessSCR2D(code_gen); break;
@@ -41,7 +41,7 @@ void FieldCameraInstruction::ProcessInst(
     case OPCODES::SCR2DL: ProcessSCR2DL(code_gen); break;
     case OPCODES::VWOFT: ProcessVWOFT(code_gen); break;
     case OPCODES::FADE: ProcessFADE(code_gen); break;
-    case OPCODES::FADEW: code_gen->WriteTodo(md.GetEntityName(), "FADEW"); break;
+    case OPCODES::FADEW: code_gen->AddOutputLine("screen_fade:wait()"); break;
     case OPCODES::SCRLP: code_gen->WriteTodo(md.GetEntityName(), "SCRLP"); break;
     case OPCODES::MVCAM: code_gen->WriteTodo(md.GetEntityName(), "MVCAM"); break;
     case OPCODES::SCRLA: ProcessSCRLA(code_gen, static_cast<FieldEngine&>(*engine)); break;
@@ -53,14 +53,9 @@ void FieldCameraInstruction::ProcessInst(
 }
 
 void FieldCameraInstruction::ProcessNFADE(CodeGenerator* code_gen){
-    // TODO: Not fully reversed.
-    auto raw_type = params_[4]->GetUnsigned();
-    if (raw_type == 0){
-        code_gen->AddOutputLine("-- fade:clear()");
-        return;
-    }
+    // Operands: the banks of R, G and B (the third nibble is always zero), the type, R, G, B
+    // and the duration in frames.
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
-    const std::string type = raw_type == 12 ? "Fade.SUBTRACT" : "Fade.ADD";
     const auto& r = FieldCodeGenerator::FormatValueOrVariable(
       cg->GetFormatter(), params_[0]->GetUnsigned(), params_[5]->GetUnsigned()
     );
@@ -68,14 +63,26 @@ void FieldCameraInstruction::ProcessNFADE(CodeGenerator* code_gen){
       cg->GetFormatter(), params_[1]->GetUnsigned(), params_[6]->GetUnsigned()
     );
     const auto& b = FieldCodeGenerator::FormatValueOrVariable(
-      cg->GetFormatter(), params_[2]->GetUnsigned(), params_[7]->GetUnsigned()
-    );
-    const auto& unknown = FieldCodeGenerator::FormatValueOrVariable(
-      cg->GetFormatter(), params_[3]->GetUnsigned(), params_[8]->GetUnsigned()
-    );
-    code_gen->AddOutputLine(
-      (boost::format("-- fade:fade(%2%, %3%, %4%, %1%, %5%)") % type % r % g % b % unknown).str()
+      cg->GetFormatter(), params_[3]->GetUnsigned(), params_[7]->GetUnsigned()
     );
+    code_gen->AddOutputLine((
+      boost::format("screen_fade:nfade(%1%, %2%, %3%, %4%, %5%)")
+      % params_[4]->GetUnsigned() % r % g % b % params_[8]->GetUnsigned()
+    ).str());
+}
+
+void FieldCameraInstruction::ProcessSHAKE(CodeGenerator* code_gen){
+    // Operands: two banks for the amplitudes (always zero in the game data), the type, and the
+    // amplitude and frames between bounces of each axis.
+    if (params_[0]->GetUnsigned() != 0 || params_[1]->GetUnsigned() != 0){
+        code_gen->WriteTodo("FieldCameraInstruction", "SHAKE with variable parameters");
+        return;
+    }
+    code_gen->AddOutputLine((
+      boost::format("background2d:shake(%1%, %2%, %3%, %4%, %5%)")
+      % params_[2]->GetUnsigned() % params_[3]->GetUnsigned() % params_[4]->GetUnsigned()
+      % params_[5]->GetUnsigned() % params_[6]->GetUnsigned()
+    ).str());
 }
 
 void FieldCameraInstruction::ProcessSCRLC(CodeGenerator* code_gen){
@@ -192,20 +199,8 @@ void FieldCameraInstruction::ProcessVWOFT(CodeGenerator* code_gen){
 }
 
 void FieldCameraInstruction::ProcessFADE(CodeGenerator* code_gen){
-    // TODO: not fully reversed
-    auto raw_type = params_[8]->GetUnsigned();
-    std::string type;
-    switch (raw_type) {
-        case 1:
-        case 2:
-        case 7:
-        case 8:
-            type = "Fade.SUBTRACT";
-            break;
-        case 4: code_gen->AddOutputLine("-- fade:black()"); return;
-        default: type = "Fade.ADD"; break;
-    }
-
+    // Operands: the banks of R, G and B (the third nibble is always zero), R, G, B, the speed,
+    // the type and the adjust, which doesn't change what is seen.
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
     const auto& r = FieldCodeGenerator::FormatValueOrVariable(
       cg->GetFormatter(), params_[0]->GetUnsigned(), params_[4]->GetUnsigned()
@@ -214,12 +209,10 @@ void FieldCameraInstruction::ProcessFADE(CodeGenerator* code_gen){
       cg->GetFormatter(), params_[1]->GetUnsigned(), params_[5]->GetUnsigned()
     );
     const auto& b = FieldCodeGenerator::FormatValueOrVariable(
-      cg->GetFormatter(), params_[2]->GetUnsigned(), params_[6]->GetUnsigned()
+      cg->GetFormatter(), params_[3]->GetUnsigned(), params_[6]->GetUnsigned()
     );
-    // TODO: needs to be divided by 30.0f?
-    auto speed = params_[7]->GetUnsigned();
-    auto start = params_[9]->GetUnsigned();
     code_gen->AddOutputLine((
-      boost::format("-- fade:fade(%2%, %3%, %4%, %1%, %5%, %6%)") % type % r % g % b % speed % start
+      boost::format("screen_fade:fade(%1%, %2%, %3%, %4%, %5%)")
+      % params_[8]->GetUnsigned() % r % g % b % params_[7]->GetUnsigned()
     ).str());
 }

+ 18 - 0
src/installer/decompiler/field/instruction/FieldCameraInstruction.h

@@ -43,6 +43,24 @@ class FieldCameraInstruction : public KernelCallInstruction{
 
         void ProcessNFADE(CodeGenerator* code_gen);
 
+        /**
+         * Processes a SHAKE opcode.
+         *
+         * Opcode: 0x5E
+         * Short name: SHAKE
+         * Long name: Shake Camera
+         *
+         * Memory layout (8 bytes)
+         * |0x5E|B1|B2|T|XA|XF|YA|YF|
+         *
+         * Adds a shake to the view, without changing the scroll. The type T is 0 to stop it, 1 to
+         * shake the X axis, 2 to shake the Y axis and 3 to shake both. XA and YA are the amplitude
+         * of the bounces, and XF and YF the frames between them.
+         *
+         * @param[in,out] code_gen Code generator to append lines.
+         */
+        void ProcessSHAKE(CodeGenerator* code_gen);
+
         /**
          * Prodesses a SCR2D opcode.
          *

+ 1 - 1
src/installer/decompiler/field/instruction/FieldCondJumpInstruction.cpp

@@ -31,7 +31,7 @@ void FieldCondJumpInstruction::ProcessInst(
     std::string func_name;
     if (opcode_ == OPCODES::IFKEYON) func_name = "entity_manager:is_key_on";
     else if (opcode_ == OPCODES::IFKEYOFF) func_name = "entity_manager:is_key_off";
-    else if (opcode_ == OPCODES::IFKEY) func_name = "Key";
+    else if (opcode_ == OPCODES::IFKEY) func_name = "entity_manager:is_key_on";
     else if (opcode_ == OPCODES::IFMEMBQ) func_name = "IFMEMBQ";
     else if (opcode_ == OPCODES::IFPRTYQ) func_name = "IFPRTYQ";
 

+ 84 - 9
src/installer/decompiler/field/instruction/FieldModelInstruction.cpp

@@ -113,7 +113,7 @@ void FieldModelInstruction::ProcessInst(
         case OPCODES::XYZ: code_gen->WriteTodo(md.GetEntityName(), "XYZ"); break;
         case OPCODES::MOVE: ProcessMOVE(code_gen, md.GetEntityName()); break;
         case OPCODES::CMOVE: code_gen->WriteTodo(md.GetEntityName(), "CMOVE"); break;
-        case OPCODES::MOVA: code_gen->WriteTodo(md.GetEntityName(), "MOVA"); break;
+        case OPCODES::MOVA: ProcessMOVA(code_gen, md.GetEntityName(), eng); break;
         case OPCODES::TURA: ProcessTURA(code_gen, md.GetEntityName(), eng); break;
         case OPCODES::ANIMW: ProcessANIMW(code_gen, md.GetEntityName()); break;
         case OPCODES::FMOVE: ProcessFMOVE(code_gen, md.GetEntityName()); break;
@@ -133,7 +133,7 @@ void FieldModelInstruction::ProcessInst(
         case OPCODES::DIR: ProcessDIR(code_gen, md.GetEntityName()); break;
         case OPCODES::TURNGEN: ProcessTURNGEN(code_gen, md.GetEntityName()); break;
         case OPCODES::TURN: code_gen->WriteTodo(md.GetEntityName(), "TURN"); break;
-        case OPCODES::DIRA: code_gen->WriteTodo(md.GetEntityName(), "DIRA"); break;
+        case OPCODES::DIRA: ProcessDIRA(code_gen, md.GetEntityName(), eng); break;
         case OPCODES::GETDIR: code_gen->WriteTodo(md.GetEntityName(), "GETDIR"); break;
         case OPCODES::GETAXY: code_gen->WriteTodo(md.GetEntityName(), "GETAXY"); break;
         case OPCODES::GETAI: ProcessGETAI(code_gen, eng); break;
@@ -146,19 +146,19 @@ void FieldModelInstruction::ProcessInst(
         case OPCODES::CANM_2:
             ProcessCANM_2(code_gen, md.GetEntityName(), md.GetCharacterId());
             break;
-        case OPCODES::ASPED: code_gen->WriteTodo(md.GetEntityName(), "ASPED"); break;
+        case OPCODES::ASPED: ProcessASPED(code_gen, md.GetEntityName()); break;
         case OPCODES::CC: ProcessCC(code_gen, eng); break;
         case OPCODES::JUMP: ProcessJUMP(code_gen, md.GetEntityName()); break;
         case OPCODES::AXYZI: ProcessAXYZI(code_gen); break;
-        case OPCODES::LADER: ProcessLADER(code_gen, md.GetEntityName()); break;
+        case OPCODES::LADER: ProcessLADER(code_gen, md.GetEntityName(), md.GetCharacterId()); break;
         case OPCODES::OFST: ProcessOFST(code_gen, md.GetEntityName()); break;
         case OPCODES::OFSTW:
             code_gen->AddOutputLine("self." + md.GetEntityName() + ":offset_sync()");
             break;
-        case OPCODES::TALKR: code_gen->WriteTodo(md.GetEntityName(), "TALKR"); break;
+        case OPCODES::TALKR: ProcessTALKR(code_gen, md.GetEntityName()); break;
         case OPCODES::SLIDR: ProcessSLIDR(code_gen, md.GetEntityName()); break;
         case OPCODES::SOLID: ProcessSOLID(code_gen, md.GetEntityName()); break;
-        case OPCODES::TLKR2: code_gen->WriteTodo(md.GetEntityName(), "TLKR2"); break;
+        case OPCODES::TLKR2: ProcessTALKR(code_gen, md.GetEntityName()); break;
         case OPCODES::SLDR2: code_gen->WriteTodo(md.GetEntityName(), "SLDR2"); break;
         case OPCODES::CCANM: code_gen->WriteTodo(md.GetEntityName(), "CCANM"); break;
         case OPCODES::FCFIX: code_gen->WriteTodo(md.GetEntityName(), "FCFIX"); break;
@@ -244,6 +244,14 @@ void FieldModelInstruction::ProcessCHAR(CodeGenerator* code_gen, const std::stri
     // a default one, which a DFANM later in the script replaces.
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
     const std::string animation = cg->GetFormatter().GetDefaultAnimationName(params_[0]->GetSigned());
+    // The walk and run animations are the second and the third of the model.
+    const std::string walk = cg->GetFormatter().GetMoveAnimationName(params_[0]->GetSigned(), 1);
+    const std::string run = cg->GetFormatter().GetMoveAnimationName(params_[0]->GetSigned(), 2);
+    if (!walk.empty() && !run.empty() && (walk != "Walk" || run != "Run")){
+        code_gen->AddOutputLine((
+          boost::format("self.%1%:set_move_animations(\"%2%\", \"%3%\")") % entity % walk % run
+        ).str());
+    }
     if (!animation.empty()){
         code_gen->AddOutputLine((
           boost::format("self.%1%:set_default_animation(\"%2%\") -- No Idle animation")
@@ -288,6 +296,71 @@ void FieldModelInstruction::ProcessANIME1(
     code_gen->AddOutputLine((boost::format("self.%1%:animation_sync()") % entity).str());
 }
 
+void FieldModelInstruction::ProcessMOVA(
+  CodeGenerator* code_gen, const std::string& entity, const FieldEngine& engine
+){
+    // Walks to the other entity, and waits until it is there.
+    const auto& target = engine.EntityByIndex(params_[0]->GetUnsigned());
+    code_gen->AddOutputLine((
+      boost::format("self.%1%:move_to_entity(entity_manager:get_entity(\"%2%\"))")
+      % entity % target.GetName()
+    ).str());
+    code_gen->AddOutputLine((boost::format("self.%1%:move_sync()") % entity).str());
+}
+
+void FieldModelInstruction::ProcessTALKR(CodeGenerator* code_gen, const std::string& entity){
+    // The range is given in the same units as the positions.
+    FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
+    const float scale = 128.0f * cg->GetScaleFactor();
+    const std::string range_value = FieldCodeGenerator::FormatValueOrVariable(
+      cg->GetFormatter(), params_[0]->GetUnsigned() + params_[1]->GetUnsigned(),
+      params_[2]->GetUnsigned(), FieldCodeGenerator::ValueType::Float, scale
+    );
+    float range = 0;
+    if (TryParseFloat(range_value, range)){
+        code_gen->AddOutputLine(
+          (boost::format("self.%1%:set_talk_radius(%2%)") % entity % range).str()
+        );
+    }
+    else{
+        // A variable: the scale is not applied yet.
+        code_gen->AddOutputLine(
+          "self." + entity + ":set_talk_radius((" + range_value + ") / " + std::to_string(scale) + ")"
+        );
+    }
+}
+
+void FieldModelInstruction::ProcessDIRA(
+  CodeGenerator* code_gen, const std::string& entity, const FieldEngine& engine
+){
+    // Turns the model towards another entity at once.
+    const auto& target = engine.EntityByIndex(params_[0]->GetUnsigned());
+    code_gen->AddOutputLine((
+      boost::format("self.%1%:face_entity(entity_manager:get_entity(\"%2%\"))")
+      % entity % target.GetName()
+    ).str());
+}
+
+void FieldModelInstruction::ProcessASPED(CodeGenerator* code_gen, const std::string& entity){
+    // The value is the speed of the animations: 16 is the normal one, 32 double and 8 half.
+    FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
+    const std::string speed_value = FieldCodeGenerator::FormatValueOrVariable(
+      cg->GetFormatter(), params_[0]->GetUnsigned() + params_[1]->GetUnsigned(),
+      params_[2]->GetUnsigned()
+    );
+    float speed = 0;
+    if (TryParseFloat(speed_value, speed)){
+        code_gen->AddOutputLine((
+          boost::format("self.%1%:set_animation_speed(%2%)") % entity % (speed / 16.0f)
+        ).str());
+    }
+    else{
+        code_gen->AddOutputLine(
+          "self." + entity + ":set_animation_speed((" + speed_value + ") / 16.0)"
+        );
+    }
+}
+
 void FieldModelInstruction::ProcessANIME2(
   CodeGenerator* code_gen, const std::string& entity, int char_id
 ){
@@ -618,7 +691,9 @@ void FieldModelInstruction::ProcessAXYZI(CodeGenerator* code_gen){
     ).str());
 }
 
-void FieldModelInstruction::ProcessLADER(CodeGenerator* code_gen, const std::string& entity){
+void FieldModelInstruction::ProcessLADER(
+  CodeGenerator* code_gen, const std::string& entity, int char_id
+){
     FieldCodeGenerator* cg = static_cast<FieldCodeGenerator*>(code_gen);
     const float scale = 128.0f * cg->GetScaleFactor();
     const auto& x = FieldCodeGenerator::FormatValueOrVariable(
@@ -644,13 +719,13 @@ void FieldModelInstruction::ProcessLADER(CodeGenerator* code_gen, const std::str
       FieldCodeGenerator::ValueType::Float, 256.0f / 360.0f
     );
     uint speed = params_[11]->GetUnsigned();
-    // TODO: Animation hardcoded as "btce".
     // TODO: Orientation and speed not set.
     code_gen->AddOutputLine((
       boost::format(
-        "self.%1%:linear_to_position(%2%, %3%, %4%, %5%, \"Climb\", %6%, %7%) "
+        "self.%1%:linear_to_position(%2%, %3%, %4%, %5%, \"%10%\", %6%, %7%) "
         "-- Animation %8% -- Speed %9%"
       ) % entity % x % y % z % keys % orientation % end_triangle % animation % speed
+        % cg->GetFormatter().GetFriendlyAnimationName(char_id, animation)
     ).str());
     code_gen->AddOutputLine((boost::format("self.%1%:linear_sync()") % entity).str());
 }

+ 73 - 1
src/installer/decompiler/field/instruction/FieldModelInstruction.h

@@ -156,6 +156,77 @@ class FieldModelInstruction : public KernelCallInstruction{
 
         void ProcessMOVE(CodeGenerator* code_gen, const std::string& entity);
 
+        /**
+         * Processes a MOVA opcode.
+         *
+         * Opcode: 0xAA
+         * Short name: MOVA
+         * Long name: Move to Entity
+         *
+         * Memory layout (2 bytes)
+         * |0xAA|E|
+         *
+         * Walks the model towards the entity with ID E, up to the point where the two touch, and
+         * waits until it gets there.
+         *
+         * @param[in,out] code_gen Code generator to append lines.
+         * @param[in] entity Name of the entity.
+         * @param[in] engine Engine, to find the entity.
+         */
+        void ProcessMOVA(
+          CodeGenerator* code_gen, const std::string& entity, const FieldEngine& engine
+        );
+
+        /**
+         * Processes a TALKR or TLKR2 opcode.
+         *
+         * Opcodes: 0xC5 and 0xD6
+         * Short names: TALKR and TLKR2
+         * Long names: Talk Range and Talk Range (16-bit)
+         *
+         * Sets how far from the model the player can start a conversation with it. It's the same
+         * for both opcodes, which only differ in the size of the value.
+         *
+         * @param[in,out] code_gen Code generator to append lines.
+         * @param[in] entity Name of the entity.
+         */
+        void ProcessTALKR(CodeGenerator* code_gen, const std::string& entity);
+
+        /**
+         * Processes a DIRA opcode.
+         *
+         * Opcode: 0xB6
+         * Short name: DIRA
+         * Long name: Direction to Entity
+         *
+         * Memory layout (2 bytes)
+         * |0xB6|E|
+         *
+         * Turns the model towards the entity with ID E at once.
+         *
+         * @param[in,out] code_gen Code generator to append lines.
+         * @param[in] entity Name of the entity.
+         * @param[in] engine Engine, to find the entity.
+         */
+        void ProcessDIRA(
+          CodeGenerator* code_gen, const std::string& entity, const FieldEngine& engine
+        );
+
+        /**
+         * Processes an ASPED opcode.
+         *
+         * Opcode: 0xBD
+         * Short name: ASPED
+         * Long name: Animation Speed
+         *
+         * Sets the speed of all the animations the model plays: 16 is normal speed, 32 is double
+         * and 8 is half.
+         *
+         * @param[in,out] code_gen Code generator to append lines.
+         * @param[in] entity Name of the entity.
+         */
+        void ProcessASPED(CodeGenerator* code_gen, const std::string& entity);
+
         /**
          * Processes an ANIME2 opcode.
          *
@@ -418,8 +489,9 @@ class FieldModelInstruction : public KernelCallInstruction{
          *
          * @param[in,out] code_gen Code generator to append lines.
          * @param[in] entity The name of the entity.
+         * @param[in] char_id ID of the character model, to name the climbing animation.
          */
-        void ProcessLADER(CodeGenerator* code_gen, const std::string& entity);
+        void ProcessLADER(CodeGenerator* code_gen, const std::string& entity, int char_id);
 
         /**
          * Processes an SLIDR opcode.

+ 1 - 1
src/installer/decompiler/field/instruction/FieldWindowInstruction.cpp

@@ -47,7 +47,7 @@ void FieldWindowInstruction::ProcessInst(
     //std::cout << "Window opcode: " << opcode_ << "\n";
     switch (opcode_){
         case OPCODES::TUTOR: code_gen->WriteTodo(md.GetEntityName(), "TUTOR"); break;
-        case OPCODES::WCLS: code_gen->WriteTodo(md.GetEntityName(), "WCLS"); break;
+        case OPCODES::WCLS: ProcessWCLSE(code_gen); break;
         case OPCODES::WSIZW: ProcessWSIZW(code_gen); break;
         case OPCODES::WSPCL: ProcessWSPCL(code_gen); break;
         case OPCODES::WNUMB: code_gen->WriteTodo(md.GetEntityName(), "WNUMB"); break;

+ 1 - 0
test/CMakeLists.txt

@@ -91,6 +91,7 @@ set(SOURCE_FILES
     core/AudioManager.cpp
     core/Background2D.cpp
     core/Background2DAnimation.cpp
+    core/ScreenFadeState.cpp
     core/CameraManager.cpp
     core/ConfigCmd.cpp
     core/ConfigCmdHandler.cpp

+ 52 - 0
test/core/EntityManager.cpp

@@ -16,6 +16,58 @@
 #include <boost/test/unit_test.hpp>
 #include "core/EntityManager.h"
 
+BOOST_AUTO_TEST_CASE(TestEntityManagerInteractionPicksTheEntityTheCharacterFaces){
+    // The maximum angle is 90 degrees, and no candidate yet.
+    const float max_angle = 90.0f;
+    const float no_distance = 1000.0f;
+    BOOST_CHECK(EntityManager::IsBetterInteractionTarget(30.0f, 1.0f, max_angle, no_distance));
+    // Facing the character more directly is better, whatever the order.
+    BOOST_CHECK(EntityManager::IsBetterInteractionTarget(5.0f, 1.0f, 30.0f, 1.0f));
+    BOOST_CHECK(!EntityManager::IsBetterInteractionTarget(30.0f, 1.0f, 5.0f, 1.0f));
+    // A candidate further from the front than the best one is not better.
+    BOOST_CHECK(!EntityManager::IsBetterInteractionTarget(120.0f, 1.0f, max_angle, no_distance));
+    // The same angle: the nearest wins, and the first one is kept on a tie.
+    BOOST_CHECK(EntityManager::IsBetterInteractionTarget(10.0f, 0.5f, 10.0f, 1.0f));
+    BOOST_CHECK(!EntityManager::IsBetterInteractionTarget(10.0f, 1.0f, 10.0f, 0.5f));
+    BOOST_CHECK(!EntityManager::IsBetterInteractionTarget(10.0f, 1.0f, 10.0f, 1.0f));
+
+    // Two characters side by side, in the list order Jessie then Wedge: facing Jessie must
+    // choose Jessie, and facing Wedge must choose Wedge.
+    const float jessie_angle_facing_jessie = 4.0f;
+    const float wedge_angle_facing_jessie = 40.0f;
+    float best_angle = max_angle;
+    float best_distance = no_distance;
+    int chosen = -1;
+    const float angles[] = {jessie_angle_facing_jessie, wedge_angle_facing_jessie};
+    for (int i = 0; i < 2; ++i){
+        if (EntityManager::IsBetterInteractionTarget(angles[i], 0.2f, best_angle, best_distance)){
+            best_angle = angles[i];
+            best_distance = 0.2f;
+            chosen = i;
+        }
+    }
+    BOOST_CHECK_EQUAL(chosen, 0);
+}
+
+BOOST_AUTO_TEST_CASE(TestEntityManagerLinesTriggerEvenIfAScriptZeroedTheRadius){
+    // A script sets the radius of the player to zero while a scene plays: lines must still work.
+    BOOST_CHECK_CLOSE(EntityManager::TriggerReach(0.0f), 0.21f, 0.01f);
+    BOOST_CHECK_CLOSE(EntityManager::TriggerReach(0.005f), 0.21f, 0.01f);
+    // A radius that a script chose is respected.
+    BOOST_CHECK_CLOSE(EntityManager::TriggerReach(0.265625f), 0.265625f, 0.01f);
+    BOOST_CHECK_CLOSE(EntityManager::TriggerReach(0.078125f), 0.078125f, 0.01f);
+}
+
+BOOST_AUTO_TEST_CASE(TestEntityManagerScriptedWalksAreNotStoppedByTheWalkmeshEdge){
+    // The player and the characters that wander are stopped.
+    BOOST_CHECK(EntityManager::WalkmeshEdgeBlocks(true, false));
+    BOOST_CHECK(EntityManager::WalkmeshEdgeBlocks(false, false));
+    // Even if a script moves the player, the player stays on the walkmesh.
+    BOOST_CHECK(EntityManager::WalkmeshEdgeBlocks(true, true));
+    // A character a script walks to a position is not.
+    BOOST_CHECK(!EntityManager::WalkmeshEdgeBlocks(false, true));
+}
+
 BOOST_AUTO_TEST_CASE(TestEntityManager){
     BOOST_CHECK_CLOSE(EntityManager::ScriptedMoveStep(0.5f, 0.0f, 0.1f), 0.1f, 0.01f);
     BOOST_CHECK_CLOSE(EntityManager::ScriptedMoveStep(0.05f, 0.0f, 0.1f), 0.05f, 0.01f);

+ 163 - 0
test/core/ScreenFadeState.cpp

@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2022 The V-Gears Team
+ *
+ * This file is part of V-Gears
+ *
+ * V-Gears is free software: you can redistribute it and/or modify it under
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, version 3.0 (GPLv3) of the License.
+ *
+ * V-Gears is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ */
+
+#include <boost/test/unit_test.hpp>
+#include "core/ScreenFadeState.h"
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateSpeedToSeconds){
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(1), 8.0f, 0.01f);
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(2), 4.0f, 0.01f);
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(4), 2.0f, 0.01f);
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(8), 1.0f, 0.01f);
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(16), 0.5f, 0.01f);
+    BOOST_CHECK_CLOSE(ScreenFadeState::SpeedToSeconds(32), 0.25f, 0.01f);
+    // A speed that is not positive is instant.
+    BOOST_CHECK_EQUAL(ScreenFadeState::SpeedToSeconds(0), 0.0f);
+    BOOST_CHECK_EQUAL(ScreenFadeState::SpeedToSeconds(-3), 0.0f);
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateNothingByDefault){
+    ScreenFadeState fade;
+    BOOST_CHECK(!fade.IsFading());
+    BOOST_CHECK(!fade.IsVisible());
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_NONE);
+    BOOST_CHECK(!fade.Advance(1.0f));
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateRedFlashAddsAndGoesBack){
+    // FADE type 5: the colour is added, and goes down to nothing. It's the red flash.
+    ScreenFadeState fade;
+    BOOST_REQUIRE(fade.StartFade(5, 255, 0, 0, 8));
+    BOOST_CHECK(fade.IsFading());
+    BOOST_CHECK(fade.IsVisible());
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_ADD);
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 1.0f, 0.01f);
+    BOOST_CHECK_SMALL(fade.GetColour().g, 0.0001f);
+
+    BOOST_CHECK(!fade.Advance(0.5f));
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 0.5f, 0.01f);
+    // It ends once, and then the field is shown again.
+    BOOST_CHECK(fade.Advance(0.5f));
+    BOOST_CHECK(!fade.IsFading());
+    BOOST_CHECK(!fade.IsVisible());
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_NONE);
+    BOOST_CHECK(!fade.Advance(1.0f));
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateFadeToColourHoldsIt){
+    // FADE type 10: the colour is added for the time of the fade, and stays.
+    ScreenFadeState fade;
+    BOOST_REQUIRE(fade.StartFade(10, 40, 80, 120, 16));
+    BOOST_CHECK(fade.Advance(0.5f));
+    BOOST_CHECK(!fade.IsFading());
+    BOOST_CHECK(fade.IsVisible());
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_ADD);
+    BOOST_CHECK_CLOSE(fade.GetColour().b, 120.0f / 255.0f, 0.01f);
+
+    // Type 9 is the same, but it goes back to the field.
+    BOOST_REQUIRE(fade.StartFade(9, 40, 80, 120, 16));
+    BOOST_CHECK(fade.Advance(0.5f));
+    BOOST_CHECK(!fade.IsVisible());
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateSubtractiveTypes){
+    // Type 2 goes from nothing to the inverse of the colour times four, and holds it.
+    ScreenFadeState fade;
+    BOOST_REQUIRE(fade.StartFade(2, 255, 255, 255, 8));
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_SUBTRACT);
+    BOOST_CHECK(fade.Advance(1.0f));
+    // White inverted is black: nothing is subtracted.
+    BOOST_CHECK_SMALL(fade.GetColour().r, 0.0001f);
+
+    // Black inverted four times is brighter than the maximum: it is limited, and takes it all.
+    BOOST_REQUIRE(fade.StartFade(2, 0, 0, 0, 8));
+    BOOST_CHECK(fade.Advance(1.0f));
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 1.0f, 0.01f);
+    BOOST_CHECK(fade.IsVisible());
+
+    // Type 1 goes the other way, and ends with the field.
+    BOOST_REQUIRE(fade.StartFade(1, 0, 0, 0, 8));
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 1.0f, 0.01f);
+    BOOST_CHECK(fade.Advance(1.0f));
+    BOOST_CHECK(!fade.IsVisible());
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateInstantBlackAndNormalFade){
+    // Type 4 makes the screen black at once, and nobody has to wait for it.
+    ScreenFadeState fade;
+    BOOST_REQUIRE(fade.StartFade(4, 12, 34, 56, 8));
+    BOOST_CHECK(!fade.IsFading());
+    BOOST_CHECK(fade.IsVisible());
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_NORMAL);
+    BOOST_CHECK_CLOSE(fade.GetAlpha(), 1.0f, 0.01f);
+    BOOST_CHECK_SMALL(fade.GetColour().r, 0.0001f);
+
+    // Type 6 covers the field with the colour little by little, and keeps it.
+    BOOST_REQUIRE(fade.StartFade(6, 255, 255, 255, 8));
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_NORMAL);
+    BOOST_CHECK_SMALL(fade.GetAlpha(), 0.0001f);
+    fade.Advance(0.5f);
+    BOOST_CHECK_CLOSE(fade.GetAlpha(), 0.5f, 0.01f);
+    BOOST_CHECK(fade.Advance(0.5f));
+    BOOST_CHECK(fade.IsVisible());
+    BOOST_CHECK_CLOSE(fade.GetAlpha(), 1.0f, 0.01f);
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateNFade){
+    // NFADE type 11 adds the colour in 30 frames, which is a second, and keeps it.
+    ScreenFadeState fade;
+    BOOST_REQUIRE(fade.StartNFade(11, 200, 100, 0, 30));
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_ADD);
+    BOOST_CHECK_SMALL(fade.GetColour().r, 0.0001f);
+    BOOST_CHECK(!fade.Advance(0.5f));
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 0.5f * 200.0f / 255.0f, 0.01f);
+    BOOST_CHECK(fade.Advance(0.5f));
+    BOOST_CHECK(fade.IsVisible());
+    BOOST_CHECK_CLOSE(fade.GetColour().r, 200.0f / 255.0f, 0.01f);
+
+    // Type 12 does the same, taking the colour from the field.
+    BOOST_REQUIRE(fade.StartNFade(12, 200, 100, 0, 30));
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_SUBTRACT);
+
+    // Type 0 clears whatever there is.
+    BOOST_REQUIRE(fade.StartNFade(0, 0, 0, 0, 0));
+    BOOST_CHECK(!fade.IsVisible());
+    BOOST_CHECK(!fade.IsFading());
+}
+
+BOOST_AUTO_TEST_CASE(TestScreenFadeStateUnknownTypesAndReplacement){
+    ScreenFadeState fade;
+    // Unknown types change nothing.
+    BOOST_CHECK(!fade.StartFade(3, 255, 0, 0, 8));
+    BOOST_CHECK(!fade.StartFade(99, 255, 0, 0, 8));
+    BOOST_CHECK(!fade.StartNFade(5, 255, 0, 0, 8));
+    BOOST_CHECK(!fade.IsVisible());
+
+    // A new fade replaces the one that is running.
+    BOOST_REQUIRE(fade.StartFade(5, 255, 0, 0, 8));
+    fade.Advance(0.5f);
+    BOOST_REQUIRE(fade.StartFade(10, 0, 255, 0, 8));
+    BOOST_CHECK_EQUAL(fade.GetBlend(), ScreenFadeState::BLEND_ADD);
+    BOOST_CHECK_SMALL(fade.GetColour().r, 0.0001f);
+    BOOST_CHECK_CLOSE(fade.GetColour().g, 1.0f, 0.01f);
+
+    // An unknown type doesn't stop the one that is running.
+    BOOST_CHECK(!fade.StartFade(3, 0, 0, 0, 8));
+    BOOST_CHECK(fade.IsFading());
+
+    fade.Reset();
+    BOOST_CHECK(!fade.IsVisible());
+    BOOST_CHECK(!fade.IsFading());
+}

+ 63 - 0
test/installer/decompiler/field/FieldDecompiler.cpp

@@ -144,6 +144,69 @@ BOOST_AUTO_TEST_CASE(TestFieldDecompilerNonWaitingAnimationsDontWait){
     BOOST_CHECK(output.find("animation_sync") == std::string::npos);
 }
 
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerMovaWalksToTheEntityAndWaits){
+    FieldScriptFormatter formatter;
+    // MOVA 0: the only entity of the synthetic field.
+    const std::string output = DecompileWith({0xAA, 0}, formatter);
+    const size_t walk = output.find("self.Door:move_to_entity(entity_manager:get_entity(\"Door\"))");
+    const size_t wait = output.find("self.Door:move_sync()");
+    BOOST_REQUIRE(walk != std::string::npos && wait != std::string::npos);
+    BOOST_CHECK(walk < wait);
+    BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerScreenFades){
+    FieldScriptFormatter formatter;
+    // FADE 255, 0, 0, speed 8, type 5 - NFADE type 11, 10, 20, 30, 15 frames - FADEW.
+    const std::string output = DecompileWith(
+      {0x6B, 0, 0, 255, 0, 0, 8, 5, 0, 0x25, 0, 0, 11, 10, 20, 30, 15, 0, 0x6C}, formatter
+    );
+    BOOST_CHECK(output.find("screen_fade:fade(5, 255, 0, 0, 8)") != std::string::npos);
+    BOOST_CHECK(output.find("screen_fade:nfade(11, 10, 20, 30, 15)") != std::string::npos);
+    BOOST_CHECK(output.find("screen_fade:wait()") != std::string::npos);
+    // They used to be comments.
+    BOOST_CHECK(output.find("-- fade:") == std::string::npos);
+    BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerWindowCloseTalkRangeAndFacing){
+    FieldScriptFormatter formatter;
+    // WCLS 2 - TALKR 64 - TLKR2 128 - DIRA 0.
+    const std::string output = DecompileWith(
+      {0x2E, 2, 0xC5, 0, 64, 0xD6, 0, 128, 0, 0xB6, 0}, formatter
+    );
+    BOOST_CHECK(output.find("dialog:dialog_close(\"2\")") != std::string::npos);
+    BOOST_CHECK(output.find("self.Door:set_talk_radius(0.5)") != std::string::npos);
+    BOOST_CHECK(output.find("self.Door:set_talk_radius(1)") != std::string::npos);
+    BOOST_CHECK(
+      output.find("self.Door:face_entity(entity_manager:get_entity(\"Door\"))") != std::string::npos
+    );
+    BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerAspedSetsTheAnimationSpeed){
+    FieldScriptFormatter formatter;
+    // ASPED 16 (normal speed), ASPED 32 (double) and ASPED 8 (half).
+    const std::string output = DecompileWith(
+      {0xBD, 0, 16, 0, 0xBD, 0, 32, 0, 0xBD, 0, 8, 0}, formatter
+    );
+    BOOST_CHECK(output.find("self.Door:set_animation_speed(1)") != std::string::npos);
+    BOOST_CHECK(output.find("self.Door:set_animation_speed(2)") != std::string::npos);
+    BOOST_CHECK(output.find("self.Door:set_animation_speed(0.5)") != std::string::npos);
+    BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
+}
+
+BOOST_AUTO_TEST_CASE(TestFieldDecompilerShakeKeepsItsParameters){
+    FieldScriptFormatter formatter;
+    // SHAKE 0, 0, 2, 0, 0, 5, 2 (shake the Y axis) and SHAKE 0, 0, 0, 0, 1, 0, 1 (stop).
+    const std::string output = DecompileWith(
+      {0x5E, 0, 0, 2, 0, 0, 5, 2, 0x5E, 0, 0, 0, 0, 1, 0, 1}, formatter
+    );
+    BOOST_CHECK(output.find("background2d:shake(2, 0, 0, 5, 2)") != std::string::npos);
+    BOOST_CHECK(output.find("background2d:shake(0, 0, 1, 0, 1)") != std::string::npos);
+    BOOST_CHECK(output.find("UNIMPLMENTED") == std::string::npos);
+}
+
 BOOST_AUTO_TEST_CASE(TestFieldDecompilerAnimwWaitsForTheAnimation){
     NamedAnimationFormatter formatter;
     // ANIM!1 3, 1 and then ANIMW: the script waits after starting the animation.