diff --git a/docs/about/Authors.rst b/docs/about/Authors.rst index a154d314b6..eefd440758 100644 --- a/docs/about/Authors.rst +++ b/docs/about/Authors.rst @@ -49,6 +49,7 @@ Clayton Hughes Clément Vuchener cvuchener Corey CoreyJ87 daedsidog daedsidog +Dailton Filho notliad Dan Amlund danamlund Daniel Brooks db48x David Nilsolm @@ -121,6 +122,7 @@ Kurik Amudnil Kévin Boissonneault KABoissonneault Lethosor lethosor LordGolias LordGolias +Magnus Anderson magnus-ISU Mark Nielson pseudodragon Mason11987 Mason11987 Matt Regul mattregul @@ -261,6 +263,7 @@ Vitaly Pronkin pronvit mifki ViTuRaS ViTuRaS Vjek vjek Vladimir Florov foxxelias +wala343 wala343 Warmist warmist Wes Malone wesQ3 Will H TSM-EVO diff --git a/docs/changelog.txt b/docs/changelog.txt index 904fcc86fb..72e1fbb2bb 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -55,6 +55,7 @@ Template for new versions: # Future ## New Tools +- `smooth-movement`: smoothly animate creature, item, and vehicle movement in the fortress viewport ## New Features diff --git a/docs/plugins/smooth-movement.rst b/docs/plugins/smooth-movement.rst new file mode 100644 index 0000000000..cdee603b89 --- /dev/null +++ b/docs/plugins/smooth-movement.rst @@ -0,0 +1,50 @@ +smooth-movement +=============== + +.. dfhack-tool:: + :summary: Smoothly animate movement in the fortress viewport. + :tags: fort interface + +This plugin makes creatures, hauled items, vehicles, and associated status +icons glide between tiles. It only changes rendering; gameplay, simulation +timing, and save data are unaffected. + +The plugin requires the SDL 2D renderer. + +Usage +----- + +:: + + enable smooth-movement + smooth-movement + smooth-movement flip on|off + smooth-movement zlevel on|off + +Running ``smooth-movement`` without arguments reports the plugin status and +current settings. Optional features are reset to their defaults whenever the +plugin is enabled. + +Examples +-------- + +``enable smooth-movement`` + Enable smooth movement on the main z-level. + +``smooth-movement flip on`` + Horizontally flip creatures so they face their direction of travel. + +``smooth-movement zlevel on`` + Also animate movement on visible z-levels below the main level. + +Settings +-------- + +``flip`` (default: off) + Flip creature sprites horizontally according to their last horizontal + movement. Items, vehicles, and designation icons keep their normal + orientation. + +``zlevel`` (default: off) + Animate movement on visible lower z-levels. Sprite flipping remains + independent of this setting. diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt index c898e5a7b0..524e14ecb8 100644 --- a/plugins/CMakeLists.txt +++ b/plugins/CMakeLists.txt @@ -121,6 +121,7 @@ if(BUILD_SUPPORTED) #dfhack_plugin(siege-engine siege-engine.cpp LINK_LIBRARIES lua) dfhack_plugin(sort sort.cpp LINK_LIBRARIES lua) #dfhack_plugin(steam-engine steam-engine.cpp) + add_subdirectory(smooth-movement) dfhack_plugin(spectate spectate.cpp LINK_LIBRARIES lua) #dfhack_plugin(stockflow stockflow.cpp LINK_LIBRARIES lua) add_subdirectory(stockpiles) diff --git a/plugins/smooth-movement/CMakeLists.txt b/plugins/smooth-movement/CMakeLists.txt new file mode 100644 index 0000000000..d2ca2a349a --- /dev/null +++ b/plugins/smooth-movement/CMakeLists.txt @@ -0,0 +1,3 @@ +dfhack_plugin(smooth-movement smooth-movement.cpp LINK_LIBRARIES lua) +target_include_directories(smooth-movement PRIVATE ${SDL2_INCLUDE_DIRS}) +dfhack_test(smooth-movement-test test_visual_animation_manager.cpp) diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp new file mode 100644 index 0000000000..2f726bad63 --- /dev/null +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -0,0 +1,981 @@ +// SPDX-License-Identifier: Zlib + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Core.h" +#include "MemAccess.h" +#include "PluginManager.h" +#include "VTableInterpose.h" + +#include "modules/DFSDL.h" + +#include "df/coord2d.h" +#include "df/graphic.h" +#include "df/graphic_viewportst.h" +#include "df/renderer_2d_base.h" +#include "df/texture_fullid.h" + +#include "visual_animation.h" + +using namespace DFHack; + +DFHACK_PLUGIN("smooth-movement"); +DFHACK_PLUGIN_IS_ENABLED(is_enabled); + +REQUIRE_GLOBAL(gps); +REQUIRE_GLOBAL(window_x); +REQUIRE_GLOBAL(window_y); +REQUIRE_GLOBAL(window_z); + +namespace { + +using tile_coveragest = std::set; + +// Runtime harness for the engine-owned visual state; gameplay data is never +// read. +decltype(&SDL_RenderCopyF) render_copy_f = nullptr; +decltype(&SDL_RenderCopyExF) render_copy_ex_f = nullptr; +decltype(&SDL_RenderFillRect) render_fill_rect = nullptr; +decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; +decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; + +visual_animation_managerst animation_manager; +tile_coveragest previous_coverage; +uint64_t visual_context_revision = 0; +const void *previous_viewport = nullptr; +struct visual_contextst { + int32_t z_level; + df::coord2d viewport_dimensions; + df::coord2d clip_min; + df::coord2d clip_max; + int32_t zoom; + df::coord2d origin; + df::coord2d screen_dimensions; + + bool operator==(const visual_contextst &) const = default; +}; + +visual_contextst previous_visual_context{}; +bool has_visual_context = false; +// Map scroll (window_x/window_y) is tracked separately from the reset +// signature: a pure pan is followed (movements are translated) instead of +// triggering a full reset, so it must NOT bump the context revision. It only +// invalidates the viewport-space blackout coverage from the prior frame. +df::coord2d previous_pan = df::coord2d(0, 0); +bool has_pan_context = false; +bool flip_enabled = false; +bool zlevel_enabled = false; + +// DF stores fire's animation frame in bits 28--31 of screentexpos_spatter_flag. +constexpr uint32_t viewport_spatter_fire_frame_shift = 28; +constexpr uint32_t viewport_spatter_fire_frame_1 = 1U << viewport_spatter_fire_frame_shift; +constexpr uint32_t viewport_spatter_fire_frame_2 = 2U << viewport_spatter_fire_frame_shift; +constexpr uint32_t viewport_spatter_fire_frame_3 = 3U << viewport_spatter_fire_frame_shift; +constexpr uint32_t viewport_spatter_fire_frame_4 = 4U << viewport_spatter_fire_frame_shift; +constexpr uint32_t fire_bits = viewport_spatter_fire_frame_1 | viewport_spatter_fire_frame_2 | + viewport_spatter_fire_frame_3 | viewport_spatter_fire_frame_4; +// DF's native renderer draws 32-pixel tiles at viewport zoom 128. +constexpr int32_t native_tile_pixels = 32; +constexpr int32_t native_viewport_zoom = 128; + +void update_visual_context(const df::renderer_2d_base *renderer, const df::graphic_viewportst *vp) { + // window_x/window_y are deliberately excluded: a horizontal/vertical scroll + // is followed, not reset. window_z (z-level) stays, since a z change is not + // followable. + const visual_contextst context = { + .z_level = window_z ? *window_z : 0, + .viewport_dimensions = df::coord2d(vp->dim_x, vp->dim_y), + // DF clip arrays hold inclusive minimum and maximum coordinates. + .clip_min = df::coord2d(vp->clipx[0], vp->clipy[0]), + .clip_max = df::coord2d(vp->clipx[1], vp->clipy[1]), + .zoom = renderer->viewport_zoom_factor, + .origin = df::coord2d(renderer->origin_x, renderer->origin_y), + .screen_dimensions = df::coord2d(gps->dimx, gps->dimy), + }; + const bool changed = + !has_visual_context || previous_viewport != vp || previous_visual_context != context; + if (changed) { + ++visual_context_revision; + previous_coverage.clear(); + } + previous_viewport = vp; + previous_visual_context = context; + has_visual_context = true; + + // On a pure pan the reset signature is unchanged, but last frame's blackout + // coverage is in the old viewport frame, so discard it (the engine repaints + // the whole scrolled viewport anyway). + const df::coord2d pan(window_x ? *window_x : 0, window_y ? *window_y : 0); + if (!has_pan_context || previous_pan != pan) + previous_coverage.clear(); + previous_pan = pan; + has_pan_context = true; +} + +using viewport_layer_memberst = int32_t *df::graphic_viewportst::*; + +struct visual_layer_bufferst { + viewport_visual_layer layer; + viewport_layer_memberst current; + viewport_layer_memberst previous; +}; + +constexpr size_t visual_layer_count = static_cast(viewport_visual_layer::count); +constexpr std::array visual_layer_buffers = { + visual_layer_bufferst{viewport_visual_layer::right, + &df::graphic_viewportst::screentexpos_right_creature, + &df::graphic_viewportst::screentexpos_right_creature_old}, + visual_layer_bufferst{viewport_visual_layer::center, &df::graphic_viewportst::screentexpos, + &df::graphic_viewportst::screentexpos_old}, + visual_layer_bufferst{viewport_visual_layer::left, + &df::graphic_viewportst::screentexpos_left_creature, + &df::graphic_viewportst::screentexpos_left_creature_old}, + visual_layer_bufferst{viewport_visual_layer::upright, + &df::graphic_viewportst::screentexpos_upright_creature, + &df::graphic_viewportst::screentexpos_upright_creature_old}, + visual_layer_bufferst{viewport_visual_layer::up, + &df::graphic_viewportst::screentexpos_up_creature, + &df::graphic_viewportst::screentexpos_up_creature_old}, + visual_layer_bufferst{viewport_visual_layer::upleft, + &df::graphic_viewportst::screentexpos_upleft_creature, + &df::graphic_viewportst::screentexpos_upleft_creature_old}, + visual_layer_bufferst{viewport_visual_layer::vehicle, + &df::graphic_viewportst::screentexpos_vehicle, + &df::graphic_viewportst::screentexpos_vehicle_old}, + visual_layer_bufferst{viewport_visual_layer::item, &df::graphic_viewportst::screentexpos_item, + &df::graphic_viewportst::screentexpos_item_old}, + visual_layer_bufferst{viewport_visual_layer::designation, + &df::graphic_viewportst::screentexpos_designation, + &df::graphic_viewportst::screentexpos_designation_old}}; + +constexpr bool valid_visual_layer_buffers() { + uint16_t layers = 0; + for (const auto &buffer : visual_layer_buffers) { + const uint16_t layer = uint16_t(1U << static_cast(buffer.layer)); + if (layers & layer) + return false; + layers |= layer; + } + return layers == uint16_t((1U << visual_layer_count) - 1); +} + +static_assert(valid_visual_layer_buffers()); + +template auto visual_layers(Viewport *vp, bool previous = false) { + using layer_pointer = std::conditional_t, const int32_t *, int32_t *>; + std::array layers{}; + for (const auto &buffer : visual_layer_buffers) + layers[static_cast(buffer.layer)] = + vp->*(previous ? buffer.previous : buffer.current); + return layers; +} + +viewport_visual_animation_inputst animation_input(df::graphic_viewportst *vp) { + const df::graphic_viewportst *const_viewport = vp; + return {vp, + df::coord2d(vp->dim_x, vp->dim_y), + visual_context_revision, + visual_layers(const_viewport), + visual_layers(const_viewport, true), + df::coord2d(window_x ? *window_x : 0, window_y ? *window_y : 0)}; +} + +// The layer buffers are freed and nulled without clearing the active flag. +bool viewport_readable(df::graphic_viewportst *vp) { + return vp != nullptr && vp->flag.bits.active && animation_input(vp).valid(); +} + +int32_t tile_size(int32_t zoom) { + return std::max(1, zoom * native_tile_pixels / native_viewport_zoom); +} + +int32_t tile_pixel(int32_t tile, int32_t origin, int32_t zoom) { + return tile_size(zoom) * tile + origin; +} + +bool inside_clip(const df::graphic_viewportst *vp, df::coord2d pos) { + return pos.x >= vp->clipx[0] && pos.x <= vp->clipx[1] && pos.y >= vp->clipy[0] && + pos.y <= vp->clipy[1]; +} + +bool inside_clip(const df::graphic_viewportst *vp, int32_t x, int32_t y) { + return inside_clip(vp, df::coord2d(x, y)); +} + +bool has_fire(const df::graphic_viewportst *vp, int32_t x, int32_t y) { + return vp->screentexpos_spatter_flag != nullptr && + (vp->screentexpos_spatter_flag[x * vp->dim_y + y] & fire_bits) != 0; +} + +template class scoped_value_restorest { + T &value; + T saved; + + public: + explicit scoped_value_restorest(T &value, T replacement = T{}) + : value(value), saved(std::exchange(value, std::move(replacement))) { + static_assert(std::is_nothrow_move_assignable_v); + } + + ~scoped_value_restorest() noexcept { value = std::move(saved); } + + scoped_value_restorest(const scoped_value_restorest &) = delete; + scoped_value_restorest &operator=(const scoped_value_restorest &) = delete; + scoped_value_restorest(scoped_value_restorest &&) = delete; + scoped_value_restorest &operator=(scoped_value_restorest &&) = delete; +}; + +template void with_zeroed_values(const Callback &callback) { callback(); } + +template +void with_zeroed_values(const Callback &callback, T &value, Values &...values) { + scoped_value_restorest zero(value); + with_zeroed_values(callback, values...); +} + +struct render_proxyst { + viewport_visual_layer layer; + coord2dst source; + df::coord2d target; + int32_t texpos; + float progress; + SDL_Texture *texture; + bool mirrored = false; + int32_t mirror_shift = 0; + tile_coveragest coverage; +}; + +struct render_coveragest { + tile_coveragest all; + std::array(visual_render_groupst::count)> groups; + std::unordered_map selected; +}; + +struct viewport_renderst { + df::graphic_viewportst *viewport; + std::vector proxies; + render_coveragest coverage; +}; + +constexpr uint16_t visual_layer_bit(viewport_visual_layer layer) { + return uint16_t(1U << static_cast(layer)); +} + +uint16_t selected_mask(const std::unordered_map &selected, int32_t index) { + const auto found = selected.find(index); + return found == selected.end() ? 0 : found->second; +} + +template +void with_suppressed_visual_layers(const std::array &layers, + int32_t index, uint16_t mask, const Callback &callback) { + if constexpr (Layer == visual_layer_count) + callback(); + else if (mask & (1U << Layer)) { + scoped_value_restorest zero(layers[Layer][index]); + with_suppressed_visual_layers(layers, index, mask, callback); + } else + with_suppressed_visual_layers(layers, index, mask, callback); +} + +template +void with_base_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_zeroed_values(callback, vp->screentexpos_background[index], + vp->screentexpos_floor_flag[index], vp->screentexpos_background_two[index], + vp->screentexpos_liquid_flag[index], vp->screentexpos_spatter_flag[index], + vp->screentexpos_spatter[index], vp->screentexpos_ramp_flag[index], + vp->screentexpos_shadow_flag[index], vp->screentexpos_building_one[index]); +} + +template +void with_main_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_base_suppressed(vp, index, + [&] { with_zeroed_values(callback, vp->screentexpos_vermin[index]); }); +} + +template +void with_upper_suppressed(df::graphic_viewportst *vp, int32_t index, const Callback &callback) { + with_main_suppressed(vp, index, [&] { + with_zeroed_values(callback, vp->screentexpos_building_two[index], + vp->screentexpos_projectile[index], vp->screentexpos_high_flow[index], + vp->screentexpos_top_shadow[index], vp->screentexpos_signpost[index]); + }); +} + +void redraw_viewport_tile(df::renderer_2d_base *renderer, const viewport_renderst &viewport, + int32_t x, int32_t y, bool defer_interface) { + df::graphic_viewportst *vp = viewport.viewport; + const int32_t index = x * vp->dim_y + y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, x, y); }; + const auto stage = [&] { + with_suppressed_visual_layers(visual_layers(vp), index, + selected_mask(viewport.coverage.selected, index), redraw); + }; + // The interface layer is the shading for levels below the camera. + // A staged tile has a sprite drawn over it afterwards, so draw_interface_only + // places it instead. + if (!defer_interface || vp->screentexpos_interface == nullptr) + stage(); + else + with_zeroed_values(stage, vp->screentexpos_interface[index]); +} + +// Every buffer the interface-only pass zeroes has to exist before it can be +// zeroed. +bool interface_pass_readable(const df::graphic_viewportst *vp) { + return vp != nullptr && vp->screentexpos_interface != nullptr && + vp->screentexpos_background != nullptr && vp->screentexpos_floor_flag != nullptr && + vp->screentexpos_background_two != nullptr && vp->screentexpos_liquid_flag != nullptr && + vp->screentexpos_spatter_flag != nullptr && vp->screentexpos_spatter != nullptr && + vp->screentexpos_ramp_flag != nullptr && vp->screentexpos_shadow_flag != nullptr && + vp->screentexpos_building_one != nullptr && vp->screentexpos_vermin != nullptr && + vp->screentexpos_building_two != nullptr && vp->screentexpos_projectile != nullptr && + vp->screentexpos_high_flow != nullptr && vp->screentexpos_signpost != nullptr; +} + +// Runs after the proxies so the shading covers them rather than sitting +// underneath. +void draw_interface_only(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, + df::coord2d pos) { + if (!interface_pass_readable(vp)) + return; + const int32_t index = pos.x * vp->dim_y + pos.y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, pos.x, pos.y); }; + const auto without_visuals = [&] { + with_suppressed_visual_layers(visual_layers(vp), index, + uint16_t((1U << visual_layer_count) - 1), redraw); + }; + with_zeroed_values(without_visuals, vp->screentexpos_background[index], + vp->screentexpos_floor_flag[index], vp->screentexpos_background_two[index], + vp->screentexpos_liquid_flag[index], vp->screentexpos_spatter_flag[index], + vp->screentexpos_spatter[index], vp->screentexpos_ramp_flag[index], + vp->screentexpos_shadow_flag[index], vp->screentexpos_building_one[index], + vp->screentexpos_vermin[index], vp->screentexpos_building_two[index], + vp->screentexpos_projectile[index], vp->screentexpos_high_flow[index], + vp->screentexpos_signpost[index]); +} + +void redraw_world_tile(df::renderer_2d_base *renderer, + const std::vector &viewports, + const tile_coveragest &staged, int32_t x, int32_t y) { + // The stage pass repaints everything above the lowest across the staged + // tiles, after the proxies. + const bool staged_tile = staged.count(df::coord2d(x, y)) != 0; + for (const viewport_renderst &viewport : viewports) { + if (inside_clip(viewport.viewport, x, y)) + redraw_viewport_tile(renderer, viewport, x, y, staged_tile); + if (staged_tile) + break; + } +} + +constexpr uint16_t visual_layers_through_group(visual_render_groupst group) { + uint16_t mask = 0; + for (const auto &descriptor : visual_layer_descriptors) + if (descriptor.render_group != visual_render_groupst::designation && + static_cast(descriptor.render_group) <= static_cast(group)) + mask |= visual_layer_bit(descriptor.layer); + return mask; +} + +void redraw_above(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, int32_t x, int32_t y, + visual_render_groupst group, + const std::unordered_map &selected) { + const int32_t index = x * vp->dim_y + y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, x, y); }; + const auto suppress_visuals = [&] { + const auto stage = [&] { + with_suppressed_visual_layers( + visual_layers(vp), index, + selected_mask(selected, index) | visual_layers_through_group(group), redraw); + }; + // The interface layer sits above every group, so each group's redraw would + // paint it again. draw_interface_only places it once, after the sprites. + if (vp->screentexpos_interface == nullptr) + stage(); + else + with_zeroed_values(stage, vp->screentexpos_interface[index]); + }; + if (group == visual_render_groupst::item || group == visual_render_groupst::vehicle) + with_base_suppressed(vp, index, suppress_visuals); + else if (group == visual_render_groupst::main) + with_main_suppressed(vp, index, suppress_visuals); + else + with_upper_suppressed(vp, index, suppress_visuals); +} + +SDL_Texture *cached_texture(df::renderer_2d_base *renderer, int32_t texpos, + bool transparent_background = true) { + if (texpos == 0) + return nullptr; + df::texture_fullid texture_id; + texture_id.texpos = texpos; + texture_id.r = texture_id.g = texture_id.b = 1.0f; + texture_id.br = texture_id.bg = texture_id.bb = 0.0f; + texture_id.flag = + transparent_background ? df::texture_fullid_flag::mask_transparent_background : 0; + const auto texture = renderer->tile_cache.tile_cache.find(texture_id); + return texture == renderer->tile_cache.tile_cache.end() + ? nullptr + : static_cast(texture->second); +} + +// The render_copy_ex_f null check is defensive only, not a graceful-degradation +// path. `bind` aborts load_sdl on any missing symbol and plugin_enable then +// refuses the render hook. +void render_copy_maybe_mirrored(SDL_Renderer *renderer, SDL_Texture *texture, + const SDL_FRect &destination, bool mirrored) { + if (mirrored && render_copy_ex_f != nullptr) { + render_copy_ex_f(renderer, texture, nullptr, &destination, 0.0, nullptr, + SDL_FLIP_HORIZONTAL); + return; + } + render_copy_f(renderer, texture, nullptr, &destination); +} + +void draw_proxy(df::renderer_2d_base *renderer, const render_proxyst &proxy) { + const int32_t zoom = renderer->viewport_zoom_factor; + const coord2dst target{ + tile_pixel(proxy.target.x, renderer->origin_x, zoom), + tile_pixel(proxy.target.y, renderer->origin_y, zoom)}; + const float tile_size = float(::tile_size(zoom)); + const float source_x = target.x + (proxy.source.x - proxy.target.x) * tile_size; + const float source_y = target.y + (proxy.source.y - proxy.target.y) * tile_size; + const float mirror_offset = float(proxy.mirror_shift) * tile_size; + const SDL_FRect destination = { + source_x + (target.x - source_x) * proxy.progress + mirror_offset, + source_y + (target.y - source_y) * proxy.progress, tile_size, tile_size}; + render_copy_maybe_mirrored(static_cast(renderer->sdl_renderer), proxy.texture, + destination, proxy.mirrored); +} + +std::vector collect_proxies(df::renderer_2d_base *renderer, + df::graphic_viewportst *vp, bool movement_enabled) { + std::vector proxies; + auto layers = visual_layers(vp); + auto previous_layers = visual_layers(vp, true); + for (const auto visual_layer : visual_layer_draw_order) { + const size_t layer = static_cast(visual_layer); + for (int32_t y = 0; y < vp->dim_y; ++y) { + for (int32_t x = 0; x < vp->dim_x; ++x) { + const int32_t index = x * vp->dim_y + y; + const int32_t texpos = layers[layer][index]; + if (texpos == 0 || !movement_enabled) + continue; + const auto movement = animation_manager.get_movement( + vp, static_cast(layer), x, y); + if (!movement.active) + continue; + const coord2dst target{float(x), float(y)}; + const auto movement_delta = movement.source - target; + const int32_t inherited_source_x = + inherited_visual_source_tile(x, movement.source.x, x); + const int32_t inherited_source_y = + inherited_visual_source_tile(y, movement.source.y, y); + const bool inherited_source_in_bounds = + inherited_source_x >= 0 && inherited_source_x < vp->dim_x && + inherited_source_y >= 0 && inherited_source_y < vp->dim_y; + if (!visual_layer_moves_independently(visual_layer)) { + bool anchored = false; + for (const render_proxyst &anchor : proxies) { + // Creature fragments lie no more than one tile from their center layer. + if (anchor.layer == viewport_visual_layer::center && + std::abs(anchor.target.x - x) <= 1 && + std::abs(anchor.target.y - y) <= 1 && + anchor.source - + coord2dst{float(anchor.target.x), float(anchor.target.y)} == + movement_delta && + anchor.progress == movement.progress) + anchored = true; + } + if (!anchored) + continue; + } + if ((visual_layer == viewport_visual_layer::item || + visual_layer == viewport_visual_layer::designation) && + movement.inherited) { + if (visual_layer == viewport_visual_layer::item && + vp->screentexpos_old[index] != 0) + continue; + if (!inherited_source_in_bounds) + continue; + const int32_t source = inherited_source_x * vp->dim_y + inherited_source_y; + if (!visual_moved_between_tiles(visual_layer, layers[layer], + previous_layers[layer], source, index)) + continue; + } + if (!visual_layer_moves_independently(visual_layer) && + visual_layer != viewport_visual_layer::designation && movement.inherited) { + const bool fragment_moved = + inherited_source_in_bounds && + visual_moved_between_tiles( + visual_layer, layers[layer], previous_layers[layer], + inherited_source_x * vp->dim_y + inherited_source_y, index); + if (!fragment_moved) { + const auto &descriptor = visual_layer_descriptor(visual_layer); + bool owns_fragment = false; + for (const render_proxyst &anchor : proxies) + if (anchor.layer == viewport_visual_layer::center && + anchor.target.x == x + descriptor.anchor_offset.x && + anchor.target.y == y + descriptor.anchor_offset.y && + anchor.source - + coord2dst{float(anchor.target.x), float(anchor.target.y)} == + movement_delta && + anchor.progress == movement.progress) + owns_fragment = true; + if (!owns_fragment) + continue; + } + } + + // Items, vehicles and designations keep their vanilla orientation. + const auto &mirror_descriptor = visual_layer_descriptor(visual_layer); + const visual_render_groupst group = visual_render_group(visual_layer); + const bool mirror_eligible = + flip_enabled && + (group == visual_render_groupst::main || group == visual_render_groupst::upper); + // Facing is read from the anchor tile so every fragment of one creature + // agrees. + const df::coord2d anchor(x + mirror_descriptor.anchor_offset.x, + y + mirror_descriptor.anchor_offset.y); + const bool mirrored = mirror_eligible && + animation_manager.get_facing(vp, anchor) != native_sprite_facing; + // The anchor's own layer has no offset, so it flips in place. + const int32_t mirror_shift = + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.anchor_offset.x) - x : 0; + render_proxyst proxy = {static_cast(layer), + movement.source, + df::coord2d(x, y), + texpos, + movement.progress, + nullptr, + mirrored, + mirror_shift, + {}}; + bool blocked = false; + const int32_t coverage_min_x = int32_t(std::floor(std::min(proxy.source.x, float(x)))); + const int32_t coverage_max_x = int32_t(std::ceil(std::max(proxy.source.x, float(x)))); + const int32_t coverage_min_y = int32_t(std::floor(std::min(proxy.source.y, float(y)))); + const int32_t coverage_max_y = int32_t(std::ceil(std::max(proxy.source.y, float(y)))); + for (int32_t coverage_x = coverage_min_x; coverage_x <= coverage_max_x; + ++coverage_x) { + for (int32_t coverage_y = coverage_min_y; coverage_y <= coverage_max_y; + ++coverage_y) { + if (!inside_clip(vp, coverage_x, coverage_y)) { + blocked = true; + break; + } + if (visual_render_group(proxy.layer) == visual_render_groupst::main && + has_fire(vp, coverage_x, coverage_y)) { + blocked = true; + break; + } + proxy.coverage.emplace(coverage_x, coverage_y); + } + if (blocked) + break; + } + if (blocked) + continue; + if (proxy.mirror_shift != 0) { + tile_coveragest mirrored_coverage; + for (const auto &tile : proxy.coverage) + mirrored_coverage.emplace(tile.x + proxy.mirror_shift, tile.y); + for (const auto &tile : mirrored_coverage) { + if (!inside_clip(vp, tile.x, tile.y)) { + blocked = true; + break; + } + if (visual_render_group(proxy.layer) == visual_render_groupst::main && + has_fire(vp, tile.x, tile.y)) { + blocked = true; + break; + } + proxy.coverage.insert(tile); + } + if (blocked) + continue; + } + + proxy.texture = cached_texture(renderer, texpos); + if (proxy.texture == nullptr) + continue; + proxies.push_back(std::move(proxy)); + } + } + } + + // A creature that has stopped still needs its mirrored sprite painted each + // frame. Otherwise the engine repaints it natively and the two orientations + // alternate between steps. A fragment's tile is its anchor minus the layer's + // centre offset, inverting the moving path. + if (flip_enabled) { + for (int32_t anchor_x = 0; anchor_x < vp->dim_x; ++anchor_x) { + for (int32_t anchor_y = 0; anchor_y < vp->dim_y; ++anchor_y) { + const df::coord2d anchor(anchor_x, anchor_y); + if (animation_manager.get_facing(vp, anchor) == native_sprite_facing) + continue; + for (const auto visual_layer : visual_layer_draw_order) { + const visual_render_groupst group = visual_render_group(visual_layer); + if (group != visual_render_groupst::main && + group != visual_render_groupst::upper) + continue; + const auto &descriptor = visual_layer_descriptor(visual_layer); + const int32_t x = anchor.x - descriptor.anchor_offset.x; + const int32_t y = anchor.y - descriptor.anchor_offset.y; + if (x < 0 || x >= vp->dim_x || y < 0 || y >= vp->dim_y) + continue; + const size_t layer = static_cast(visual_layer); + const int32_t texpos = layers[layer][x * vp->dim_y + y]; + if (texpos == 0) + continue; + bool already_drawn = false; + for (const render_proxyst &existing : proxies) + if (existing.layer == visual_layer && existing.target.x == x && + existing.target.y == y) + already_drawn = true; + if (already_drawn) + continue; + + // source == target at progress 1.0 draws in place, moved only by + // mirror_shift. + render_proxyst proxy = {visual_layer, + {float(x), float(y)}, + df::coord2d(x, y), + texpos, + 1.0f, + nullptr, + true, + mirrored_tile_x(x, anchor.x) - x, + {}}; + // The sprite lands on x+mirror_shift, so that interval must be + // repaintable. The shift has either sign, so order the interval ends + // first. + const int32_t coverage_first = std::min(x, x + proxy.mirror_shift); + const int32_t coverage_last = std::max(x, x + proxy.mirror_shift); + bool blocked = false; + for (int32_t coverage_x = coverage_first; coverage_x <= coverage_last; + ++coverage_x) { + if (!inside_clip(vp, coverage_x, y) || + (group == visual_render_groupst::main && has_fire(vp, coverage_x, y))) { + blocked = true; + break; + } + proxy.coverage.emplace(coverage_x, y); + } + if (blocked) + continue; + + proxy.texture = cached_texture(renderer, texpos); + if (proxy.texture == nullptr) + continue; + proxies.push_back(std::move(proxy)); + } + } + } + } + return proxies; +} + +render_coveragest collect_coverage(const std::vector &proxies, int32_t dim_y) { + render_coveragest coverage; + for (const render_proxyst &proxy : proxies) { + coverage.all.insert(proxy.coverage.begin(), proxy.coverage.end()); + // TODO: use std::mdspan when C++23 available + coverage.selected[proxy.target.x * dim_y + proxy.target.y] |= visual_layer_bit(proxy.layer); + auto &group = coverage.groups[static_cast(visual_render_group(proxy.layer))]; + group.insert(proxy.coverage.begin(), proxy.coverage.end()); + } + return coverage; +} + +std::vector active_viewports() { + std::vector viewports; + if (gps == nullptr) + return viewports; + for (size_t lower = std::size(gps->lower_viewport); lower-- > 0;) { + df::graphic_viewportst *vp = gps->lower_viewport[lower]; + if (viewport_readable(vp)) + viewports.push_back(vp); + } + if (viewport_readable(gps->main_viewport)) + viewports.push_back(gps->main_viewport); + return viewports; +} + +std::vector +collect_viewport_renders(df::renderer_2d_base *renderer, + const std::vector &viewports) { + std::vector renders; + renders.reserve(viewports.size()); + for (df::graphic_viewportst *vp : viewports) { + const bool movement_enabled = zlevel_enabled || vp == gps->main_viewport; + viewport_renderst render = {vp, collect_proxies(renderer, vp, movement_enabled), {}}; + render.coverage = collect_coverage(render.proxies, vp->dim_y); + renders.push_back(std::move(render)); + } + return renders; +} + +tile_coveragest collect_viewport_coverage(const std::vector &viewports) { + tile_coveragest coverage; + for (const viewport_renderst &viewport : viewports) + coverage.insert(viewport.coverage.all.begin(), viewport.coverage.all.end()); + return coverage; +} + +void draw_interpolation_stages(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, + const std::vector &proxies, + const render_coveragest &coverage) { + for (size_t index = 0; index < coverage.groups.size(); ++index) { + const auto group = static_cast(index); + for (const render_proxyst &proxy : proxies) + if (visual_render_group(proxy.layer) == group) + draw_proxy(renderer, proxy); + if (group == visual_render_groupst::designation) + continue; + for (const auto &[x, y] : coverage.groups[index]) + redraw_above(renderer, vp, x, y, group, coverage.selected); + } +} + +void redraw_viewport_tiles(df::renderer_2d_base *renderer, const viewport_renderst &viewport, + const tile_coveragest &coverage) { + df::graphic_viewportst *vp = viewport.viewport; + for (const df::coord2d &tile : coverage) { + if (!inside_clip(vp, tile)) + continue; + redraw_viewport_tile(renderer, viewport, tile.x, tile.y, true); + } +} + +void draw_viewport_interpolation_stages(df::renderer_2d_base *renderer, + const std::vector &viewports, + const tile_coveragest &coverage) { + for (size_t index = 0; index < viewports.size(); ++index) { + // A lower z-level's proxy must be covered by the next viewport's fog and + // terrain. Reapply that viewport before its own proxies, matching DF's + // lower-to-main draw order. + if (index > 0) + redraw_viewport_tiles(renderer, viewports[index], coverage); + const viewport_renderst &viewport = viewports[index]; + draw_interpolation_stages(renderer, viewport.viewport, viewport.proxies, viewport.coverage); + // A viewport shades everything drawn beneath it, so this covers every + // staged tile. Restricting it to the tiles this viewport has sprites on + // would not deepen with distance. + for (const df::coord2d &tile : coverage) { + if (inside_clip(viewport.viewport, tile)) + draw_interface_only(renderer, viewport.viewport, tile); + } + } +} + +bool has_mirrored_viewport_facing(const std::vector &viewports) { + for (const df::graphic_viewportst *vp : viewports) + if (animation_manager.has_mirrored_facing(vp)) + return true; + return false; +} + +void render_interpolated_world(df::renderer_2d_base *renderer) { + df::graphic_viewportst *vp = gps ? gps->main_viewport : nullptr; + const std::vector viewports = active_viewports(); + + if (vp != nullptr) + update_visual_context(renderer, vp); + const uint32_t now_ms = Core::getInstance().p->getTickCount(); + animation_manager.begin_frame(now_ms); + for (df::graphic_viewportst *viewport : viewports) + animation_manager.synchronize_viewport(animation_input(viewport)); + animation_manager.end_frame(); + + if (!viewport_readable(vp) || renderer->sdl_renderer == nullptr) + return; + const bool animation_redraw = + zlevel_enabled ? animation_manager.requires_full_redraw() + : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); + if (!animation_redraw && (!flip_enabled || !has_mirrored_viewport_facing(viewports))) + return; + + std::vector viewport_renders = collect_viewport_renders(renderer, viewports); + tile_coveragest coverage = collect_viewport_coverage(viewport_renders); + + SDL_Renderer *sdl_renderer = static_cast(renderer->sdl_renderer); + const int32_t zoom = renderer->viewport_zoom_factor; + const int32_t tile_size = ::tile_size(zoom); + + tile_coveragest redraw_coverage = coverage; + redraw_coverage.insert(previous_coverage.begin(), previous_coverage.end()); + Uint8 old_r = 0, old_g = 0, old_b = 0, old_a = 255; + get_render_draw_color(sdl_renderer, &old_r, &old_g, &old_b, &old_a); + set_render_draw_color(sdl_renderer, 0, 0, 0, 255); + for (const auto &[x, y] : redraw_coverage) { + if (!inside_clip(vp, x, y)) + continue; + const SDL_Rect tile_rect = {tile_pixel(x, renderer->origin_x, zoom), + tile_pixel(y, renderer->origin_y, zoom), tile_size, tile_size}; + render_fill_rect(sdl_renderer, &tile_rect); + } + set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); + + for (const auto &[x, y] : redraw_coverage) { + if (inside_clip(vp, x, y)) + redraw_world_tile(renderer, viewport_renders, coverage, x, y); + } + draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); + + previous_coverage = std::move(coverage); +} + +struct renderer_hook : df::renderer_2d_base { + typedef df::renderer_2d_base interpose_base; + DEFINE_VMETHOD_INTERPOSE(void, update_all, ()); +}; + +IMPLEMENT_VMETHOD_INTERPOSE(renderer_hook, update_all); + +void renderer_hook::interpose_fn_update_all() { + // update_all is the existing UI stage, so world correction must run first. + render_interpolated_world(this); + INTERPOSE_NEXT(update_all)(); +} + +void clear_sdl_bindings() { + render_copy_f = nullptr; + render_copy_ex_f = nullptr; + render_fill_rect = nullptr; + get_render_draw_color = nullptr; + set_render_draw_color = nullptr; +} + +bool load_sdl(color_ostream &out) { + clear_sdl_bindings(); + DFLibrary *sdl_handle = DFSDL::obtain_library_handle(); +#define bind(name, target) \ + target = reinterpret_cast(LookupPlugin(sdl_handle, #name)); \ + if (target == nullptr) { \ + out.printerr("smooth-movement: SDL2 function unavailable: " #name "\n"); \ + clear_sdl_bindings(); \ + return false; \ + } + bind(SDL_RenderCopyF, render_copy_f); + bind(SDL_RenderCopyExF, render_copy_ex_f); + bind(SDL_RenderFillRect, render_fill_rect); + bind(SDL_GetRenderDrawColor, get_render_draw_color); + bind(SDL_SetRenderDrawColor, set_render_draw_color); +#undef bind + return true; +} + +void reset_state() { + animation_manager = visual_animation_managerst(); + previous_coverage.clear(); + visual_context_revision = 0; + previous_viewport = nullptr; + previous_visual_context = {}; + has_visual_context = false; + previous_pan = {}; + has_pan_context = false; + flip_enabled = false; + zlevel_enabled = false; +} + +command_result status_command(color_ostream &out, std::vector ¶meters) { + if (parameters.empty()) { + out.print("smooth-movement: {}\n", is_enabled ? "enabled" : "disabled"); + out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); + out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + return CR_OK; + } + if (parameters[0] == "flip") { + if (parameters.size() == 1) { + out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); + return CR_OK; + } + // A toggle changes the screen without changing anything DF knows, so DF + // will not repaint. OFF matters most: the render path stops touching tiles + // it painted every frame. The last mirrored frame would persist. Same flush + // plugin_enable(false) uses. + if (parameters.size() == 2 && parameters[1] == "on") { + flip_enabled = true; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: sprite flipping enabled\n"); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "off") { + flip_enabled = false; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: sprite flipping disabled\n"); + return CR_OK; + } + return CR_WRONG_USAGE; + } + if (parameters[0] == "zlevel") { + if (parameters.size() == 1) { + out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "on") + zlevel_enabled = true; + else if (parameters.size() == 2 && parameters[1] == "off") + zlevel_enabled = false; + else + return CR_WRONG_USAGE; + if (gps != nullptr) + ++gps->force_full_display_count; + out.print("smooth-movement: lower z-level animation {}\n", + zlevel_enabled ? "enabled" : "disabled"); + return CR_OK; + } + return CR_WRONG_USAGE; +} + +} // namespace + +DFhackCExport command_result plugin_init(color_ostream &, std::vector &commands) { + commands.emplace_back("smooth-movement", "Smoothly animate movement in the fortress viewport.", + status_command); + return CR_OK; +} + +DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { + if (is_enabled == enable) + return CR_OK; + if (enable) { + reset_state(); + if (!load_sdl(out)) + return CR_FAILURE; + if (!INTERPOSE_HOOK(renderer_hook, update_all).apply()) { + out.printerr("smooth-movement: could not hook the 2D renderer\n"); + clear_sdl_bindings(); + return CR_FAILURE; + } + } else { + INTERPOSE_HOOK(renderer_hook, update_all).remove(); + reset_state(); + clear_sdl_bindings(); + if (gps != nullptr) + ++gps->force_full_display_count; + } + is_enabled = enable; + out.print("smooth-movement: {}\n", enable ? "enabled" : "disabled"); + return CR_OK; +} + +DFhackCExport command_result plugin_shutdown(color_ostream &out) { + return plugin_enable(out, false); +} diff --git a/plugins/smooth-movement/test_visual_animation_manager.cpp b/plugins/smooth-movement/test_visual_animation_manager.cpp new file mode 100644 index 0000000000..3de026a5cb --- /dev/null +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -0,0 +1,778 @@ +// SPDX-License-Identifier: Zlib + +#include +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include + +#include "visual_animation.h" + +namespace { + +template +struct test_grid { + static constexpr int32_t dimension = Dimension; + + std::array tiles{}; + + int32_t &at(int32_t x, int32_t y) { return tiles[y * dimension + x]; } + int32_t &operator[](size_t index) { return tiles[index]; } + const int32_t *data() const { return tiles.data(); } + void fill(int32_t value) { tiles.fill(value); } +}; + +viewport_visual_animation_inputst make_input(const void *viewport, int32_t dimension, + const int32_t *empty) { + viewport_visual_animation_inputst input; + input.viewport = static_cast(viewport); + input.dimensions = df::coord2d(dimension, dimension); + input.context_revision = 1; + input.current.fill(empty); + input.previous.fill(empty); + return input; +} + +template +viewport_visual_animation_inputst make_input(const void *viewport, + const test_grid &empty) { + return make_input(viewport, Dimension, empty.data()); +} + +void set_layer(viewport_visual_animation_inputst &input, viewport_visual_layer layer, + const int32_t *current, const int32_t *previous) { + const size_t index = static_cast(layer); + input.current[index] = current; + input.previous[index] = previous; +} + +template +void set_layer(viewport_visual_animation_inputst &input, viewport_visual_layer layer, + const test_grid ¤t, const test_grid &previous) { + set_layer(input, layer, current.data(), previous.data()); +} + +template +bool moved_between_tiles(viewport_visual_layer layer, const test_grid ¤t, + const test_grid &previous, df::coord2d source, + df::coord2d target) { + return visual_moved_between_tiles(layer, current.data(), previous.data(), + source.y * Dimension + source.x, + target.y * Dimension + target.x); +} + +template +void clear_layers(viewport_visual_animation_inputst &input, const test_grid &empty) { + input.current.fill(empty.data()); + input.previous.fill(empty.data()); +} + +void run_frame(visual_animation_managerst &manager, const viewport_visual_animation_inputst &input, + uint32_t now_ms) { + manager.begin_frame(now_ms); + manager.synchronize_viewport(input); + manager.end_frame(); +} + +} // namespace + +int main() { + visual_animation_managerst manager; + manager.begin_frame(1000); + assert(manager.get_frame_time_ms() == 1000); + assert(manager.get_frame_delta_ms() == 0); + + manager.begin_frame(1020); + assert(manager.get_frame_time_ms() == 1020); + assert(manager.get_frame_delta_ms() == 20); + + manager.begin_frame(1020); + assert(manager.get_frame_delta_ms() == 0); + + visual_animation_managerst rollover; + rollover.begin_frame(std::numeric_limits::max() - 5); + rollover.begin_frame(3); + assert(rollover.get_frame_delta_ms() == 9); + // Facing rule: only a horizontal component changes facing. + assert(facing_after_move(1, visual_facingst::west) == visual_facingst::east); + assert(facing_after_move(-1, visual_facingst::east) == visual_facingst::west); + // dy is not an input, so a diagonal is only ever the sign of dx. + // The four real diagonals run end to end through the manager further down. + assert(facing_after_move(1, visual_facingst::east) == visual_facingst::east); + assert(facing_after_move(-1, visual_facingst::west) == visual_facingst::west); + // Pure vertical and idle carry the previous facing (sticky). + assert(facing_after_move(0, visual_facingst::west) == visual_facingst::west); + assert(facing_after_move(0, visual_facingst::east) == visual_facingst::east); + + constexpr int32_t mirror_anchor_x = 5; + constexpr int32_t mirror_right_x = mirror_anchor_x + 1; + constexpr int32_t mirror_left_x = mirror_anchor_x - 1; + assert(mirrored_tile_x(mirror_anchor_x, mirror_anchor_x) == mirror_anchor_x); + assert(mirrored_tile_x(mirror_right_x, mirror_anchor_x) == mirror_left_x); + assert(mirrored_tile_x(mirror_left_x, mirror_anchor_x) == mirror_right_x); + // The formula is a general reflection, so it holds for offsets no layer can + // express. + assert(mirrored_tile_x(mirror_anchor_x + 3, mirror_anchor_x) == mirror_anchor_x - 3); + // The horizontal anchor offset bounds the real mirror shift to -2, 0 or +2. + for (const auto &descriptor : visual_layer_descriptors) + assert(descriptor.anchor_offset.x >= -1 && descriptor.anchor_offset.x <= 1); + // Reflection is self-inverse. + assert(mirrored_tile_x(mirrored_tile_x(mirror_right_x, mirror_anchor_x), mirror_anchor_x) == + mirror_right_x); + assert(mirrored_tile_x(mirrored_tile_x(mirror_anchor_x + 3, mirror_anchor_x), + mirror_anchor_x) == mirror_anchor_x + 3); + + { + constexpr int32_t dim = 4; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t initial_frame_ms = 1000; + constexpr uint32_t frame_interval_ms = 16; + int32_t empty[dim * dim] = {}; + int32_t before[dim * dim] = {}; + int32_t west_after[dim * dim] = {}; + const int viewport_token = 0; + const void *viewport = &viewport_token; + + before[2 * dim + 2] = creature_texpos; + west_after[1 * dim + 2] = creature_texpos; // moved west: x 2 -> 1 + + // Moving west sets west facing on the target tile. + { + visual_animation_managerst manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(!manager.has_active_movement(viewport)); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.has_active_movement(viewport)); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + } + + // Moving east sets east facing. + // East is neither the grid default nor the source facing, so the assertion + // is not vacuous. + { + visual_animation_managerst manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + set_layer(input, viewport_visual_layer::center, before, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == visual_facingst::east); + } + + // The mirrored flag gates the render path's early return. + // It must rise only for a genuinely mirrored creature and fall when that + // tile empties. + { + visual_animation_managerst manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(!manager.has_mirrored_facing(viewport)); + // Moving west matches the art, so nothing is mirrored yet. + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + assert(!manager.has_mirrored_facing(viewport)); + // Moving east faces away from the art and raises the flag. + set_layer(input, viewport_visual_layer::center, before, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == visual_facingst::east); + assert(manager.has_mirrored_facing(viewport)); + // The creature leaves: the tile clears and so does the flag. + set_layer(input, viewport_visual_layer::center, empty, before); + run_frame(manager, input, initial_frame_ms + 3 * frame_interval_ms); + assert(manager.get_facing(viewport, 2, 2) == native_sprite_facing); + assert(!manager.has_mirrored_facing(viewport)); + assert(!manager.has_mirrored_facing(nullptr)); + } + + // Pure vertical movement carries the existing facing to the new tile. + { + visual_animation_managerst manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, initial_frame_ms + frame_interval_ms); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + int32_t up[dim * dim] = {}; + up[1 * dim + 1] = creature_texpos; // north: (1,2) -> (1,1), no horizontal component + set_layer(input, viewport_visual_layer::center, up, west_after); + run_frame(manager, input, initial_frame_ms + 2 * frame_interval_ms); + assert(manager.get_facing(viewport, 1, 1) == visual_facingst::west); + } + + // Out-of-range and unknown viewports fall back to the native facing. + { + visual_animation_managerst manager; + auto input = make_input(viewport, dim, empty); + set_layer(input, viewport_visual_layer::center, before, empty); + run_frame(manager, input, initial_frame_ms); + assert(manager.get_facing(viewport, -1, 0) == native_sprite_facing); + assert(manager.get_facing(viewport, dim, 0) == native_sprite_facing); + assert(manager.get_facing(nullptr, 0, 0) == native_sprite_facing); + } + + // Each rendered z-level has its own viewport buffers; tracking one must not + // suppress another. + { + const int lower_token = 0; + const int main_token = 0; + const void *lower_viewport = &lower_token; + const void *main_viewport = &main_token; + visual_animation_managerst z_levels; + auto lower_input = make_input(lower_viewport, dim, empty); + auto main_input = make_input(main_viewport, dim, empty); + set_layer(lower_input, viewport_visual_layer::center, before, empty); + set_layer(main_input, viewport_visual_layer::center, before, empty); + z_levels.begin_frame(initial_frame_ms); + z_levels.synchronize_viewport(lower_input); + z_levels.synchronize_viewport(main_input); + z_levels.end_frame(); + set_layer(lower_input, viewport_visual_layer::center, west_after, before); + set_layer(main_input, viewport_visual_layer::center, west_after, before); + z_levels.begin_frame(initial_frame_ms + frame_interval_ms); + z_levels.synchronize_viewport(lower_input); + z_levels.synchronize_viewport(main_input); + z_levels.end_frame(); + assert( + z_levels.get_movement(lower_viewport, viewport_visual_layer::center, 1, 2).active); + assert( + z_levels.get_movement(main_viewport, viewport_visual_layer::center, 1, 2).active); + } + } + + // All four diagonals through the manager, so every step carries a real dy as + // well as a dx. The chain alternates direction, so no assertion can pass by + // inheriting the previous facing. + { + constexpr int32_t diag_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t initial_frame_ms = 1000; + constexpr uint32_t frame_interval_ms = 16; + int32_t diag_empty[diag_dim * diag_dim] = {}; + int32_t start[diag_dim * diag_dim] = {}; + int32_t north_east[diag_dim * diag_dim] = {}; + int32_t south_west[diag_dim * diag_dim] = {}; + int32_t south_east[diag_dim * diag_dim] = {}; + int32_t north_west[diag_dim * diag_dim] = {}; + const int diag_token = 0; + const void *diag_viewport = &diag_token; + + start[2 * diag_dim + 2] = creature_texpos; // (2,2) + north_east[3 * diag_dim + 1] = creature_texpos; // (2,2) -> (3,1): dx +1, dy -1 + south_west[2 * diag_dim + 2] = creature_texpos; // (3,1) -> (2,2): dx -1, dy +1 + south_east[3 * diag_dim + 3] = creature_texpos; // (2,2) -> (3,3): dx +1, dy +1 + north_west[2 * diag_dim + 2] = creature_texpos; // (3,3) -> (2,2): dx -1, dy -1 + + visual_animation_managerst diagonal; + auto input = make_input(diag_viewport, diag_dim, diag_empty); + set_layer(input, viewport_visual_layer::center, start, diag_empty); + run_frame(diagonal, input, initial_frame_ms); + assert(diagonal.get_facing(diag_viewport, 2, 2) == native_sprite_facing); + + set_layer(input, viewport_visual_layer::center, north_east, start); + run_frame(diagonal, input, initial_frame_ms + frame_interval_ms); + assert(diagonal.get_facing(diag_viewport, 3, 1) == visual_facingst::east); + + // West is the grid default, so the westward legs also assert a movement was + // registered. Otherwise an untracked step leaving the tile at its default + // would pass. + set_layer(input, viewport_visual_layer::center, south_west, north_east); + run_frame(diagonal, input, initial_frame_ms + 2 * frame_interval_ms); + assert(diagonal.get_movement(diag_viewport, viewport_visual_layer::center, 2, 2).active); + assert(diagonal.get_facing(diag_viewport, 2, 2) == visual_facingst::west); + + set_layer(input, viewport_visual_layer::center, south_east, south_west); + run_frame(diagonal, input, initial_frame_ms + 3 * frame_interval_ms); + assert(diagonal.get_facing(diag_viewport, 3, 3) == visual_facingst::east); + + set_layer(input, viewport_visual_layer::center, north_west, south_east); + run_frame(diagonal, input, initial_frame_ms + 4 * frame_interval_ms); + assert(diagonal.get_movement(diag_viewport, viewport_visual_layer::center, 2, 2).active); + assert(diagonal.get_facing(diag_viewport, 2, 2) == visual_facingst::west); + } + + // Facing is keyed by SCREEN tile, so a scroll moves the creatures out from + // under it. The two outcomes differ fundamentally: a landed shift has a known + // delta and is translated. An abandoned shift never identifies a delta, so + // the grid can only be dropped. + { + using pan_grid = test_grid<4>; + constexpr int32_t creature_texpos = 77; + constexpr uint32_t frame_interval_ms = 16; + pan_grid pan_empty; + pan_grid at_one; + pan_grid at_two; + pan_grid unmatched_a; + pan_grid unmatched_b; + const int pan_token = 0; + const void *pan_viewport = &pan_token; + + at_one.at(1, 1) = creature_texpos; + at_two.at(2, 1) = creature_texpos; // steps east, x 1 -> 2, so it faces east + unmatched_a.at(2, 1) = 78; + unmatched_b.at(2, 1) = 79; + + // LANDED: the shift is recognized, so facing follows the buffers. + { + visual_animation_managerst landed; + auto input = make_input(pan_viewport, pan_empty); + set_layer(input, viewport_visual_layer::center, at_one, pan_empty); + run_frame(landed, input, 1000); + set_layer(input, viewport_visual_layer::center, at_two, at_one); + run_frame(landed, input, 1000 + frame_interval_ms); + assert(landed.get_facing(pan_viewport, 2, 1) == visual_facingst::east); + assert(landed.has_mirrored_facing(pan_viewport)); + + // Frame A: the scroll is announced but the buffers have not moved yet. + input.pan = df::coord2d(1, 0); + set_layer(input, viewport_visual_layer::center, at_two, at_two); + run_frame(landed, input, 1000 + 2 * frame_interval_ms); + assert(landed.get_facing(pan_viewport, 2, 1) == visual_facingst::east); + + // Frame B: the buffers shift east by one and the majority-match test + // recognizes it. + set_layer(input, viewport_visual_layer::center, at_one, at_two); + run_frame(landed, input, 1000 + 3 * frame_interval_ms); + assert(landed.get_facing(pan_viewport, 1, 1) == visual_facingst::east); + assert(landed.get_facing(pan_viewport, 2, 1) == native_sprite_facing); + assert(landed.has_mirrored_facing(pan_viewport)); + } + + // ABANDONED: the shift never shows up in the buffers, so no delta is ever + // identified. The grid must be back at the default while the tile is still + // OCCUPIED. The empty-tile sweep cannot reach that case, so only an + // explicit reset clears it. + { + visual_animation_managerst abandoned; + auto input = make_input(pan_viewport, pan_empty); + set_layer(input, viewport_visual_layer::center, at_one, pan_empty); + run_frame(abandoned, input, 2000); + set_layer(input, viewport_visual_layer::center, at_two, at_one); + run_frame(abandoned, input, 2000 + frame_interval_ms); + assert(abandoned.get_facing(pan_viewport, 2, 1) == visual_facingst::east); + + // Changed buffers keep the failed majority-match test running every + // frame. It tolerates four before giving up on the fifth. + input.pan = df::coord2d(1, 0); + constexpr int32_t pending_frame_count = 4; + for (int32_t frame = 0; frame < pending_frame_count; ++frame) { + set_layer(input, viewport_visual_layer::center, at_two, + frame % 2 == 0 ? unmatched_a : unmatched_b); + run_frame(abandoned, input, 2000 + uint32_t(frame + 2) * frame_interval_ms); + // Still pending, so the facing survives. + // The assertion after the giving-up frame therefore tests the reset, + // not an empty grid. + assert(abandoned.get_facing(pan_viewport, 2, 1) == visual_facingst::east); + assert(abandoned.has_mirrored_facing(pan_viewport)); + } + set_layer(input, viewport_visual_layer::center, at_two, unmatched_a); + run_frame(abandoned, input, + 2000 + uint32_t(pending_frame_count + 2) * frame_interval_ms); + assert(abandoned.get_facing(pan_viewport, 2, 1) == native_sprite_facing); + assert(!abandoned.has_mirrored_facing(pan_viewport)); + } + } + + // Regression: A and B move in the same frame, chained -- A's target tile is + // B's source tile. A's write must not corrupt B's read of its own pre-frame + // facing. A is sent east first, so its facing differs from the default B + // carries and a swap shows. + { + constexpr int32_t chase_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr int32_t companion_texpos = 88; + int32_t chase_empty[chase_dim * chase_dim] = {}; + int32_t frame_a[chase_dim * chase_dim] = {}; + int32_t frame_b[chase_dim * chase_dim] = {}; + int32_t frame_c[chase_dim * chase_dim] = {}; + const int chase_token = 0; + const void *chase_viewport = &chase_token; + + frame_a[1 * chase_dim + 1] = creature_texpos; // A at (1,1) + frame_a[2 * chase_dim + 2] = companion_texpos; // B at (2,2), stationary + frame_b[2 * chase_dim + 1] = creature_texpos; // A steps east: (1,1) -> (2,1) + frame_b[2 * chase_dim + 2] = companion_texpos; // B unchanged + + // Both step south in the same frame: shared_movement_delta needs two tiles + // on the same delta. + frame_c[2 * chase_dim + 2] = creature_texpos; // A: (2,1) -> (2,2) + frame_c[2 * chase_dim + 3] = companion_texpos; // B: (2,2) -> (2,3) + + visual_animation_managerst manager; + auto input = make_input(chase_viewport, chase_dim, chase_empty); + set_layer(input, viewport_visual_layer::center, frame_a, chase_empty); + run_frame(manager, input, 1000); + set_layer(input, viewport_visual_layer::center, frame_b, frame_a); + run_frame(manager, input, 1016); + assert(manager.get_facing(chase_viewport, 2, 1) == visual_facingst::east); + assert(manager.get_facing(chase_viewport, 2, 2) == native_sprite_facing); + + set_layer(input, viewport_visual_layer::center, frame_c, frame_b); + run_frame(manager, input, 1032); + assert(manager.get_facing(chase_viewport, 2, 2) == visual_facingst::east); + // B carries its own default forward, not A's, though A wrote (2,2) earlier + // in the same pass. + assert(manager.get_facing(chase_viewport, 2, 3) == native_sprite_facing); + } + + // Regression: a vacated source tile is reoccupied the same frame by an + // UNTRACKED creature. Nothing targets that tile, so no target write clears + // it, and it is not empty either. Only an explicit, order-independent source + // clear restores the default there. + { + constexpr int32_t gap_dim = 5; + constexpr int32_t creature_texpos = 77; + constexpr int32_t companion_texpos = 88; + constexpr int32_t untracked_texpos = 55; + int32_t gap_empty[gap_dim * gap_dim] = {}; + int32_t frame_a[gap_dim * gap_dim] = {}; + int32_t frame_b[gap_dim * gap_dim] = {}; + int32_t frame_c[gap_dim * gap_dim] = {}; + const int gap_token = 0; + const void *gap_viewport = &gap_token; + + // E is a companion so that D's later departure shares a delta with E's own + // move. shared_movement_delta engages only once two tiles move on the same + // delta. + frame_a[1 * gap_dim + 1] = creature_texpos; // D at (1,1) + frame_a[2 * gap_dim + 2] = companion_texpos; // E at (2,2), stationary companion + frame_b[2 * gap_dim + 1] = + creature_texpos; // D steps east: (1,1) -> (2,1), facing differs from default + frame_b[2 * gap_dim + 2] = companion_texpos; // E unchanged + + // D and E both step south, chained, and F appears at D's just-vacated tile + // the same frame. previous[(2,1)] was occupied by D, so the empty-cell + // fallback cannot see F. No shared-delta source matches F's texpos either, + // so its arrival registers no movement. + frame_c[2 * gap_dim + 2] = creature_texpos; // D: (2,1) -> (2,2) + frame_c[2 * gap_dim + 3] = companion_texpos; // E: (2,2) -> (2,3) + frame_c[2 * gap_dim + 1] = untracked_texpos; // F appears at (2,1), untracked + + visual_animation_managerst manager; + auto input = make_input(gap_viewport, gap_dim, gap_empty); + set_layer(input, viewport_visual_layer::center, frame_a, gap_empty); + run_frame(manager, input, 1000); + set_layer(input, viewport_visual_layer::center, frame_b, frame_a); + run_frame(manager, input, 1016); + assert(manager.get_facing(gap_viewport, 2, 1) == visual_facingst::east); + + set_layer(input, viewport_visual_layer::center, frame_c, frame_b); + run_frame(manager, input, 1032); + assert(!manager.get_movement(gap_viewport, viewport_visual_layer::center, 2, 1).active); + // F must not inherit D's stale east facing, though the tile is occupied + // rather than empty. + assert(manager.get_facing(gap_viewport, 2, 1) == native_sprite_facing); + assert(manager.get_facing(gap_viewport, 2, 2) == visual_facingst::east); + assert(manager.get_facing(gap_viewport, 2, 3) == native_sprite_facing); + } + + constexpr uint32_t animation_duration_ms = 100; + assert(animation_progress(animation_duration_ms, 0, animation_duration_ms) == 1.0f); + assert(inherited_visual_source_tile(0, 0, 1) == -1); + assert(inherited_visual_source_tile(2, 0, 1) == 1); + assert(visual_layer_descriptor(viewport_visual_layer::right).anchor_offset.x == -1); + assert(visual_layer_descriptor(viewport_visual_layer::left).anchor_offset.x == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upright).anchor_offset.x == -1 && + visual_layer_descriptor(viewport_visual_layer::upright).anchor_offset.y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::up).anchor_offset.x == 0 && + visual_layer_descriptor(viewport_visual_layer::up).anchor_offset.y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upleft).anchor_offset.x == 1 && + visual_layer_descriptor(viewport_visual_layer::upleft).anchor_offset.y == 1); + + using small_grid = test_grid<3>; + constexpr int32_t creature_texpos = 42; + small_grid empty; + small_grid current; + small_grid previous; + const void *viewport = reinterpret_cast(uintptr_t(1)); + auto input = make_input(viewport, empty); + set_layer(input, viewport_visual_layer::center, current, previous); + + visual_animation_managerst movement; + constexpr uint32_t movement_start_time_ms = 2000; + run_frame(movement, input, movement_start_time_ms - 10); + assert(!movement.requires_full_redraw()); + + previous.at(0, 1) = creature_texpos; + current.at(1, 1) = creature_texpos; + run_frame(movement, input, movement_start_time_ms); + auto render = movement.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(render.active); + assert(render.source.x == 0 && render.source.y == 1); + assert(render.progress == 0.0f); + assert(movement.requires_full_redraw()); + + previous = current; + set_layer(input, viewport_visual_layer::center, current, previous); + run_frame(movement, input, movement_start_time_ms + animation_duration_ms / 2); + render = movement.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(render.active); + assert(render.progress == 0.5f); + + run_frame(movement, input, movement_start_time_ms + animation_duration_ms); + assert(!movement.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + assert(movement.requires_full_redraw()); + + run_frame(movement, input, movement_start_time_ms + animation_duration_ms + 20); + assert(!movement.requires_full_redraw()); + + visual_animation_managerst ambiguous; + run_frame(ambiguous, input, 2990); + previous.fill(0); + previous[0 * 3 + 1] = 42; + previous[1 * 3 + 0] = 42; + set_layer(input, viewport_visual_layer::center, current, previous); + run_frame(ambiguous, input, 3000); + assert(!ambiguous.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // A handler and led animal form an occupied chain: each enters the other's + // old space. + visual_animation_managerst convoy; + current.fill(0); + previous.fill(0); + run_frame(convoy, input, 3490); + previous[0 * 3 + 1] = 41; + previous[1 * 3 + 1] = 42; + current[1 * 3 + 1] = 41; + current[2 * 3 + 1] = 42; + run_frame(convoy, input, 3500); + const auto animal = convoy.get_movement(viewport, viewport_visual_layer::center, 1, 1); + const auto handler = convoy.get_movement(viewport, viewport_visual_layer::center, 2, 1); + assert(animal.active && animal.source.x == 0 && animal.source.y == 1); + assert(handler.active && handler.source.x == 1 && handler.source.y == 1); + + // A multi-tile fragment can use its own movement or its mapped center tile as + // proof of ownership. + for (const auto layer : + {viewport_visual_layer::right, viewport_visual_layer::left, viewport_visual_layer::upright, + viewport_visual_layer::up, viewport_visual_layer::upleft}) { + current.fill(0); + previous.fill(0); + previous[0 * 3 + 1] = 50; + current[1 * 3 + 1] = 50; + assert(moved_between_tiles(layer, current, previous, {0, 1}, {1, 1})); + previous[1 * 3 + 1] = 50; + assert(!moved_between_tiles(layer, current, previous, {0, 1}, {1, 1})); + } + + visual_animation_managerst context; + current.fill(0); + previous.fill(0); + run_frame(context, input, 4000); + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + run_frame(context, input, 4010); + assert(context.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + ++input.context_revision; + run_frame(context, input, 4020); + assert(!context.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // Camera-pan handling. window_x/window_y change at input time but the buffers + // shift on a later render frame, so the manager must (a) NOT create movements + // from the buffer shift itself (the floating-sprite bug), and (b) translate + // in-flight movements on the frame the shift lands. + small_grid pan_current; + small_grid pan_previous; + small_grid pan_empty; + auto pan_input = make_input(viewport, pan_empty); + set_layer(pan_input, viewport_visual_layer::center, pan_current, pan_previous); + + // FLOAT REGRESSION: a stationary creature, pan announced at frame A, buffers + // shift at frame B. Frame B's buffers look exactly like a real move + // ((1,1)->(0,1) with a unique source) — the manager must recognize it as the + // pending pan and create NO movement. + visual_animation_managerst floaty; + pan_previous[1 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(floaty, pan_input, 4990); + pan_input.pan = df::coord2d(1, 0); // frame A: window scrolled, buffers unchanged + run_frame(floaty, pan_input, 5000); + assert(!floaty.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_previous[1 * 3 + 1] = 42; // frame B: buffers apply the shift + pan_current.fill(0); + pan_current[0 * 3 + 1] = 42; + run_frame(floaty, pan_input, 5010); + assert(!floaty.get_movement(viewport, viewport_visual_layer::center, 0, 1).active); + + // FOLLOW: an in-flight movement survives the announce frame untouched and is + // translated on the frame the buffers shift, so the sprite tracks the + // scrolled world. + visual_animation_managerst panner; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + run_frame(panner, pan_input, 5990); + pan_previous.at(0, 1) = creature_texpos; // creature steps (0,1) -> (1,1) + pan_current.at(1, 1) = creature_texpos; + run_frame(panner, pan_input, 6000); + auto moved = panner.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(moved.active && moved.source.x == 0 && moved.source.y == 1); + + pan_input.pan = df::coord2d(1, 0); // frame A: pan announced, buffers unchanged + pan_previous[0 * 3 + 1] = 0; + pan_previous[1 * 3 + 1] = 42; // previous now matches current (stationary at (1,1)) + run_frame(panner, pan_input, 6010); + moved = panner.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(moved.active && moved.source.x == 0); // untouched: still anchored to the old frame + + pan_current.fill(0); // frame B: buffers shift east by one + pan_current[0 * 3 + 1] = 42; + run_frame(panner, pan_input, 6020); + assert(!panner.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + auto followed = panner.get_movement(viewport, viewport_visual_layer::center, 0, 1); + assert(followed.active && followed.source.x == -1 && followed.source.y == 1); + + // SAME-FRAME: pan announced and buffers shifted in the same call — translated + // immediately. + visual_animation_managerst same_frame; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + run_frame(same_frame, pan_input, 6990); + pan_previous[0 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(same_frame, pan_input, 7000); + assert(same_frame.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_input.pan = df::coord2d(1, 0); + pan_previous = pan_current; + pan_current.fill(0); + pan_current[0 * 3 + 1] = 42; + run_frame(same_frame, pan_input, 7010); + followed = same_frame.get_movement(viewport, viewport_visual_layer::center, 0, 1); + assert(followed.active && followed.source.x == -1); + + // A change that is NOT a pure pan (context revision bump) still resets, even + // with in-flight work. + visual_animation_managerst reset_on_zoom; + pan_current.fill(0); + pan_previous.fill(0); + pan_input.pan = df::coord2d(0, 0); + pan_input.context_revision = 1; + run_frame(reset_on_zoom, pan_input, 8000); + pan_previous[0 * 3 + 1] = 42; + pan_current[1 * 3 + 1] = 42; + run_frame(reset_on_zoom, pan_input, 8010); + assert(reset_on_zoom.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + pan_input.context_revision = 2; // e.g. zoom / z-level / resize + run_frame(reset_on_zoom, pan_input, 8020); + assert(!reset_on_zoom.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + + // Status fragments inherit nearby center motion even while their texture + // flashes. + small_grid status_current; + small_grid status_previous; + current.fill(0); + previous.fill(0); + input.context_revision = 1; + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::center, current, previous); + set_layer(input, viewport_visual_layer::item, status_current, status_previous); + set_layer(input, viewport_visual_layer::designation, status_current, status_previous); + visual_animation_managerst companion; + constexpr uint32_t companion_start_time_ms = 9000; + run_frame(companion, input, companion_start_time_ms - 10); + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + status_previous[0 * 3 + 0] = 90; + status_current[1 * 3 + 0] = 91; + assert(moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[0 * 3 + 0] = 0; + assert(moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + assert(!moved_between_tiles(viewport_visual_layer::item, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[1 * 3 + 0] = 80; + assert(!moved_between_tiles(viewport_visual_layer::designation, status_current, status_previous, + {0, 0}, {1, 0})); + status_previous[0 * 3 + 0] = 90; + status_previous[1 * 3 + 0] = 0; + run_frame(companion, input, companion_start_time_ms); + auto status = companion.get_movement(viewport, viewport_visual_layer::designation, 1, 0); + assert(status.active && !status.inherited && status.source.x == 0 && status.source.y == 0); + const auto carried_item = companion.get_movement(viewport, viewport_visual_layer::item, 1, 0); + assert(carried_item.active && carried_item.inherited); + previous = current; + status_current[1 * 3 + 0] = 92; + run_frame(companion, input, companion_start_time_ms + animation_duration_ms / 2); + status = companion.get_movement(viewport, viewport_visual_layer::designation, 1, 0); + assert(status.active && status.progress == 0.5f); + + // Divergent nearby creature movements make companion ownership ambiguous, so + // the overlay snaps. + current.fill(0); + previous.fill(0); + status_current.fill(0); + status_previous.fill(0); + visual_animation_managerst crowd; + run_frame(crowd, input, 9990); + previous[0 * 3 + 0] = 41; + current[0 * 3 + 1] = 41; + previous[2 * 3 + 2] = 42; + current[2 * 3 + 1] = 42; + status_current[1 * 3 + 1] = 99; + run_frame(crowd, input, 10000); + assert(!crowd.get_movement(viewport, viewport_visual_layer::designation, 1, 1).active); + status_previous[1 * 3 + 0] = 90; + status_current[1 * 3 + 1] = 91; + run_frame(crowd, input, 10010); + status = crowd.get_movement(viewport, viewport_visual_layer::designation, 1, 1); + assert(status.active); + assert(!status.inherited); + assert(status.source.x == 1 && status.source.y == 0); + + // Wheelbarrows use the item layer and the same independent adjacent-movement + // detection. + current.fill(0); + previous.fill(0); + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::item, current, previous); + visual_animation_managerst item; + run_frame(item, input, 10990); + previous[0 * 3 + 1] = 77; + current[1 * 3 + 1] = 77; + run_frame(item, input, 11000); + const auto item_move = item.get_movement(viewport, viewport_visual_layer::item, 1, 1); + assert(item_move.active && item_move.source.x == 0 && item_move.source.y == 1); + + // Minecart graphics can change texpos while moving; vehicle identity is tile + // occupancy. + current.fill(0); + previous.fill(0); + clear_layers(input, empty); + set_layer(input, viewport_visual_layer::vehicle, current, previous); + visual_animation_managerst vehicle; + constexpr int32_t vehicle_texpos_before = 77; + constexpr int32_t vehicle_texpos_during_move = 78; + constexpr int32_t vehicle_texpos_stationary = 79; + constexpr int32_t vehicle_texpos_after_move = 80; + constexpr uint32_t vehicle_start_time_ms = 12000; + run_frame(vehicle, input, vehicle_start_time_ms - 10); + previous.at(0, 1) = vehicle_texpos_before; + current.at(1, 1) = vehicle_texpos_during_move; + run_frame(vehicle, input, vehicle_start_time_ms); + assert(vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 1, 1).active); + previous = current; + current.at(1, 1) = vehicle_texpos_stationary; + run_frame(vehicle, input, vehicle_start_time_ms + animation_duration_ms / 2); + const auto cart = vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 1, 1); + assert(cart.active && cart.progress == 0.5f); + previous = current; + current.fill(0); + current.at(2, 1) = vehicle_texpos_after_move; + run_frame(vehicle, input, vehicle_start_time_ms + animation_duration_ms / 2 + 10); + const auto chained = vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 2, 1); + assert(chained.active && chained.source.x > 0.0f && chained.source.x < 1.0f && + chained.progress == 0.0f); +} diff --git a/plugins/smooth-movement/visual_animation.h b/plugins/smooth-movement/visual_animation.h new file mode 100644 index 0000000000..5f61ec07f1 --- /dev/null +++ b/plugins/smooth-movement/visual_animation.h @@ -0,0 +1,895 @@ +// SPDX-License-Identifier: Zlib + +#ifndef SMOOTH_MOVEMENT_VISUAL_ANIMATION_H +#define SMOOTH_MOVEMENT_VISUAL_ANIMATION_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "DataDefs.h" + +namespace df { +struct graphic_viewportst; +} + +template +struct coord2dst { + T x = 0; + T y = 0; + + constexpr bool operator==(const coord2dst &other) const { return x == other.x && y == other.y; } + constexpr bool operator!=(const coord2dst &other) const { return x != other.x || y != other.y; } + constexpr bool operator<(const coord2dst &other) const { + return x != other.x ? x < other.x : y < other.y; + } + constexpr coord2dst operator+(const coord2dst &other) const { return {x + other.x, y + other.y}; } + constexpr coord2dst operator-(const coord2dst &other) const { return {x - other.x, y - other.y}; } + constexpr coord2dst operator/(int number) const { + return {(x < 0 ? x - number : x) / number, (y < 0 ? y - number : y) / number}; + } + constexpr coord2dst operator*(int number) const { return {x * number, y * number}; } + constexpr coord2dst operator%(int number) const { return {(x + number) % number, (y + number) % number}; } + constexpr coord2dst operator&(int number) const { return {x & number, y & number}; } +}; + +enum class viewport_visual_layer : uint8_t { + right, + center, + left, + upright, + up, + upleft, + vehicle, + item, + designation, + count +}; + +enum class visual_render_groupst : uint8_t { item, vehicle, main, upper, designation, count }; + +struct visual_layer_descriptorst { + viewport_visual_layer layer; + visual_render_groupst render_group; + bool moves_independently; + bool matches_any_previous; + coord2dst anchor_offset; +}; + +constexpr std::array visual_layer_descriptors = { + visual_layer_descriptorst{.layer = viewport_visual_layer::right, + .render_group = visual_render_groupst::main, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {-1, 0}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::center, + .render_group = visual_render_groupst::main, + .moves_independently = true, + .matches_any_previous = false, + .anchor_offset = {0, 0}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::left, + .render_group = visual_render_groupst::main, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {1, 0}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::upright, + .render_group = visual_render_groupst::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {-1, 1}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::up, + .render_group = visual_render_groupst::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {0, 1}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::upleft, + .render_group = visual_render_groupst::upper, + .moves_independently = false, + .matches_any_previous = false, + .anchor_offset = {1, 1}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::vehicle, + .render_group = visual_render_groupst::vehicle, + .moves_independently = true, + .matches_any_previous = true, + .anchor_offset = {0, 0}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::item, + .render_group = visual_render_groupst::item, + .moves_independently = true, + .matches_any_previous = false, + .anchor_offset = {0, 0}}, + visual_layer_descriptorst{.layer = viewport_visual_layer::designation, + .render_group = visual_render_groupst::designation, + .moves_independently = false, + .matches_any_previous = true, + .anchor_offset = {0, 0}}}; + +constexpr std::array visual_layer_draw_order = { + viewport_visual_layer::center, viewport_visual_layer::item, + viewport_visual_layer::vehicle, viewport_visual_layer::right, + viewport_visual_layer::left, viewport_visual_layer::upright, + viewport_visual_layer::up, viewport_visual_layer::upleft, + viewport_visual_layer::designation}; + +constexpr bool valid_visual_layer_descriptors() { + for (size_t i = 0; i < visual_layer_descriptors.size(); ++i) { + const auto &descriptor = visual_layer_descriptors[i]; + if (static_cast(descriptor.layer) != i) + return false; + } + return true; +} + +static_assert(valid_visual_layer_descriptors()); + +constexpr bool valid_visual_layer_draw_order() { + uint16_t layers = 0; + for (const auto layer : visual_layer_draw_order) { + const uint16_t bit = uint16_t(1U << static_cast(layer)); + if (layers & bit) + return false; + layers |= bit; + } + return layers == uint16_t((1U << static_cast(viewport_visual_layer::count)) - 1); +} + +static_assert(valid_visual_layer_draw_order()); + +constexpr const visual_layer_descriptorst &visual_layer_descriptor(viewport_visual_layer layer) { + return visual_layer_descriptors[static_cast(layer)]; +} + +constexpr visual_render_groupst visual_render_group(viewport_visual_layer layer) { + return visual_layer_descriptor(layer).render_group; +} + +constexpr bool visual_layer_moves_independently(viewport_visual_layer layer) { + return visual_layer_descriptor(layer).moves_independently; +} + +constexpr bool visual_layer_tracks_own_movement(viewport_visual_layer layer) { + const auto &descriptor = visual_layer_descriptor(layer); + return descriptor.moves_independently || + descriptor.render_group == visual_render_groupst::designation; +} + +constexpr bool visual_layer_matches(viewport_visual_layer layer, int32_t current, + int32_t previous) { + return visual_layer_descriptor(layer).matches_any_previous ? previous != 0 + : previous == current; +} + +struct viewport_visual_animation_inputst { + const df::graphic_viewportst *viewport = nullptr; + df::coord2d dimensions = df::coord2d(0, 0); + uint64_t context_revision = 0; + std::array(viewport_visual_layer::count)> current{}; + std::array(viewport_visual_layer::count)> previous{}; + // Current map-scroll offset (window_x/window_y). A pure pan does not bump + // context_revision. Only a hint: it changes at input time, the buffers shift + // on a later render frame. + df::coord2d pan = df::coord2d(0, 0); + + bool valid() const { + if (viewport == nullptr || dimensions.x <= 0 || dimensions.y <= 0) + return false; + for (size_t layer = 0; layer < current.size(); ++layer) { + if (current[layer] == nullptr || previous[layer] == nullptr) + return false; + } + return true; + } +}; + +struct visual_movement_renderst { + bool active = false; + coord2dst source; + float progress = 1.0f; + bool inherited = false; +}; + +inline float animation_progress(uint32_t now_ms, uint32_t start_time_ms, uint32_t duration_ms) { + const float linear = std::min(1.0f, float(now_ms - start_time_ms) / duration_ms); + return linear * linear * (3.0f - 2.0f * linear); +} + +inline bool visual_moved_between_tiles(viewport_visual_layer layer, const int32_t *current, + const int32_t *previous, int32_t source, int32_t target) { + return previous[target] == 0 && current[source] == 0 && + (layer == viewport_visual_layer::designation || previous[source] != 0); +} + +inline int32_t inherited_visual_source_tile(int32_t overlay_target, float center_source, + float center_target) { + return overlay_target + int32_t(std::lround(center_source - center_target)); +} + +enum class visual_facingst : int8_t { east = 0, west = 1 }; + +// DF creature art faces west, so only east needs flipping. Also the default and +// cleared value. +constexpr visual_facingst native_sprite_facing = visual_facingst::west; + +// Sticky facing: only a horizontal component changes it. +constexpr visual_facingst facing_after_move(int32_t dx, visual_facingst previous) { + if (dx > 0) + return visual_facingst::east; + if (dx < 0) + return visual_facingst::west; + return previous; +} + +// anchor_offset.x is only -1, 0 or +1, so a creature is at most three columns wide +// here. +constexpr int32_t mirrored_tile_x(int32_t piece_x, int32_t anchor_x) { + return anchor_x - (piece_x - anchor_x); +} + +class visual_animation_managerst { + struct movementst { + viewport_visual_layer layer; + int32_t texpos; + coord2dst source; + df::coord2d target; + uint32_t start_time_ms; + }; + + struct viewport_animationst { + const df::graphic_viewportst *viewport = nullptr; + df::coord2d dimensions = df::coord2d(0, 0); + uint64_t context_revision = 0; + bool has_context = false; + bool seen = false; + std::vector movements; + // One facing per tile, not per unit: the viewport exposes one creature + // texpos per tile. + std::vector facing; + // Stationary mirrored creatures are repainted every frame; this is the + // cheap pre-check. + bool has_mirrored = false; + df::coord2d pan = df::coord2d(0, 0); + bool has_pan = false; + // Window scrolls not yet observed in the buffers, oldest first. + // A signed total would cancel on a reversing drag while both shifts are + // still owed. + std::vector pending; + // Redraws no prefix has matched. + int32_t pending_frames = 0; + // Redraws spent waiting for the buffers to move at all. + int32_t pending_age = 0; + // Redraws left in which new-movement detection stays suppressed after + // scroll activity. + int32_t suppress_frames = 0; + // Buffer contents last seen, to recognize a repeat of them. + std::optional buffer_signature; + // Set while the previous buffer still belongs to a view that has been left + // behind. + bool previous_view_stale = false; + }; + + uint32_t frame_time_ms = 0; + uint32_t frame_delta_ms = 0; + bool has_frame = false; + bool force_full_redraw = false; + std::vector viewports; + + // Tuned visual transition length; this is not a DF timing contract. + static constexpr uint32_t movement_duration_ms = 100; + // Scrolling faster than detection keeps up: give up rather than test ever + // more prefixes. + static constexpr size_t max_pending_shifts = 8; + // Bounds the wait on a scroll that never lands, so suppression cannot stick + // forever. These are observed recovery limits, not viewport limits. + static constexpr int32_t max_pending_age_frames = 120; + // A majority of visible, position-bearing sprites must confirm a scroll. + static constexpr double min_scroll_match_ratio = 0.5; + // Wait for buffer settling before treating changes as creature movement. + static constexpr int32_t scroll_settle_redraws = 2; + // Give up when a queued scroll does not match this many redraws. + static constexpr int32_t max_unmatched_scroll_redraws = 4; + + static void clear_pending(viewport_animationst &state) { + state.pending.clear(); + state.pending_frames = 0; + state.pending_age = 0; + } + + static void abandon_pending(viewport_animationst &state) { + state.movements.clear(); + clear_pending(state); + } + + static void reset_facing(viewport_animationst &state) { + std::fill(state.facing.begin(), state.facing.end(), int8_t(native_sprite_facing)); + state.has_mirrored = false; + } + + static void reset_tracking(viewport_animationst &state) { + abandon_pending(state); + state.suppress_frames = 0; + } + + // Identifies the buffer contents this frame, to tell a redrawn viewport from + // a repeated one. + static uint64_t compute_buffer_signature(const viewport_visual_animation_inputst &input) { + // FNV-1a. Only ever compared against the previous frame's value, never + // stored. + constexpr uint64_t fnv_offset_basis = 0xcbf29ce484222325ULL; + constexpr uint64_t fnv_prime = 0x100000001b3ULL; + uint64_t hash = fnv_offset_basis; + const int32_t tile_count = input.dimensions.x * input.dimensions.y; + for (size_t layer = 0; layer < input.current.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + for (int32_t i = 0; i < tile_count; ++i) { + hash = (hash ^ uint64_t(uint32_t(input.current[layer][i]))) * fnv_prime; + hash = (hash ^ uint64_t(uint32_t(input.previous[layer][i]))) * fnv_prime; + } + } + return hash; + } + + // Fraction of tracked sprites consistent with a buffer shift: + // current[x]==previous[x+dwx]. Negative when there is nothing to compare. + static double shift_match_ratio(const viewport_visual_animation_inputst &input, int32_t dwx, + int32_t dwy) { + int32_t considered = 0; + int32_t matches = 0; + for (size_t layer = 0; layer < input.current.size(); ++layer) { + const auto id = static_cast(layer); + if (!visual_layer_tracks_own_movement(id)) + continue; + // A layer matching any non-zero previous carries no position, so it would + // vote for every hypothesis and carry an unapplied scroll over the bar. + if (visual_layer_descriptor(id).matches_any_previous) + continue; + const int32_t *current = input.current[layer]; + const int32_t *previous = input.previous[layer]; + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t texpos = current[x * input.dimensions.y + y]; + if (texpos == 0) + continue; + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dimensions.y) + continue; + ++considered; + if (visual_layer_matches(id, texpos, previous[sx * input.dimensions.y + sy])) + ++matches; + } + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); + } + + static df::coord2d shared_movement_delta(std::span current, + std::span previous, + int32_t dim_x, int32_t dim_y) { + df::coord2d best(0, 0); + int32_t best_count = 1; + bool ambiguous = false; + for (int32_t dx = -1; dx <= 1; ++dx) { + for (int32_t dy = -1; dy <= 1; ++dy) { + if (dx == 0 && dy == 0) + continue; + int32_t count = 0; + for (int32_t x = 0; x < dim_x; ++x) { + const int32_t source_x = x + dx; + if (source_x < 0 || source_x >= dim_x) + continue; + for (int32_t y = 0; y < dim_y; ++y) { + const int32_t source_y = y + dy; + if (source_y < 0 || source_y >= dim_y) + continue; + const int32_t target = x * dim_y + y; + const int32_t texpos = current[target]; + if (texpos != 0 && previous[target] != texpos && + previous[source_x * dim_y + source_y] == texpos) + ++count; + } + } + if (count > best_count) { + best_count = count; + best = df::coord2d(dx, dy); + ambiguous = false; + } else if (count == best_count && count > 1) + ambiguous = true; + } + } + return ambiguous ? df::coord2d(0, 0) : best; + } + + viewport_animationst &get_viewport(const viewport_visual_animation_inputst &input) { + for (viewport_animationst &state : viewports) { + if (state.viewport == input.viewport) + return state; + } + viewports.emplace_back(); + viewports.back().viewport = input.viewport; + return viewports.back(); + } + + float movement_progress(uint32_t start_time_ms) const { + return animation_progress(frame_time_ms, start_time_ms, movement_duration_ms); + } + + public: + visual_animation_managerst() = default; + + void begin_frame(uint32_t now_ms) { + frame_delta_ms = has_frame ? now_ms - frame_time_ms : 0; + frame_time_ms = now_ms; + has_frame = true; + force_full_redraw = false; + // Keep one final full redraw when the last movement expires. + for (viewport_animationst &state : viewports) { + state.seen = false; + if (!state.movements.empty()) + force_full_redraw = true; + } + } + + void synchronize_viewport(const viewport_visual_animation_inputst &input) { + if (input.viewport == nullptr) + return; + viewport_animationst &state = get_viewport(input); + state.seen = true; + + if (!input.valid()) { + reset_tracking(state); + state.has_context = false; + state.has_mirrored = false; + return; + } + + // Only a replaced view leaves a previous buffer belonging somewhere else; a + // first sighting does not. + const bool view_switched = + state.has_context && + (state.context_revision != input.context_revision || state.dimensions != input.dimensions); + const bool context_changed = !state.has_context || view_switched; + // The scroll delta is queued here as a hint; the buffers are + // hypothesis-tested each frame to find where it lands. Detection stays + // suppressed until then: a shifted buffer makes every panned creature look + // like a real move. + if (state.has_pan && state.pan != input.pan) { + if (state.pending.size() >= max_pending_shifts) { + // Same give-up as the other two sites: the owed shifts are unknowable + // now. + abandon_pending(state); + reset_facing(state); + } + state.pending.emplace_back(input.pan - state.pan); + state.pending_frames = 0; + state.suppress_frames = scroll_settle_redraws; + } + state.context_revision = input.context_revision; + state.dimensions = input.dimensions; + state.has_context = true; + state.pan = input.pan; + state.has_pan = true; + if (context_changed) { + state.facing.assign(size_t(input.dimensions.x) * size_t(input.dimensions.y), + int8_t(native_sprite_facing)); + state.has_mirrored = false; + } + // This hook runs per frame; the viewport is recomputed only when it + // changes, and while paused hardly at all. Re-reading a landed scroll steps + // every sprite by a tile. + const uint64_t signature = compute_buffer_signature(input); + const bool buffers_advanced = + !state.buffer_signature || *state.buffer_signature != signature; + state.buffer_signature = signature; + + if (context_changed) { + // Skips the recompute sweep, so clear has_mirrored here or a stale true + // survives. + state.has_mirrored = false; + reset_tracking(state); + // window_z, zoom and resize change at input time; the buffers cross + // later. This reset covers only the input frame, not the crossing itself. + if (view_switched) + state.previous_view_stale = true; + return; + } + + // On the crossing frame `current` is the new view and `previous` the old + // one, so a sprite on each side, a tile apart, reads as one that moved + // between them. + const bool crossed_views = buffers_advanced && state.previous_view_stale; + if (buffers_advanced) + state.previous_view_stale = false; + // The new view is drawn at the current window, so a queued scroll is + // already in it. Left queued it would never match, and suppress everything + // until it aged out. + if (crossed_views) + clear_pending(state); + + bool translated = false; + df::coord2d landed_shift(0, 0); + if (buffers_advanced && !crossed_views && !state.pending.empty()) { + // Queued scrolls land in order and may coalesce, so each hypothesis is a + // prefix. Shortest first: over-retiring leaves owed shifts to be read as + // movement. + df::coord2d landed(0, 0); + size_t landed_count = 0; + bool any_data = false; + df::coord2d shift(0, 0); + for (size_t count = 1; count <= state.pending.size(); ++count) { + shift = shift + state.pending[count - 1]; + // A prefix netting to zero is indistinguishable from "nothing landed + // yet". Accepting it would retire shifts the buffers have still to + // apply. + if (shift == df::coord2d{0, 0}) + continue; + const double ratio = shift_match_ratio(input, shift.x, shift.y); + // Emptiness is per-prefix: a long one can push every sprite out of + // range while a shorter one still has something to say. + if (ratio < 0.0) + continue; + any_data = true; + if (ratio >= min_scroll_match_ratio) { + landed = shift; + landed_count = count; + break; + } + } + if (!any_data) { + // Nothing visible to anchor the test on: nothing to animate either. + abandon_pending(state); + reset_facing(state); + } else if (landed_count > 0) { + // Re-anchor in-flight movements and drop anything scrolled off-screen. + const int32_t dwx = landed.x; + const int32_t dwy = landed.y; + state.movements.erase( + std::remove_if(state.movements.begin(), state.movements.end(), + [&](movementst &movement) { + movement.source.x -= dwx; + movement.source.y -= dwy; + movement.target.x -= dwx; + movement.target.y -= dwy; + return movement.target.x < 0 || + movement.target.x >= input.dimensions.x || + movement.target.y < 0 || + movement.target.y >= input.dimensions.y; + }), + state.movements.end()); + // Facing describes creatures still on screen, so translate it rather + // than drop it. + if (state.facing.size() == + size_t(input.dimensions.x) * size_t(input.dimensions.y)) { + std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dimensions.y) + continue; + shifted[x * input.dimensions.y + y] = + state.facing[sx * input.dimensions.y + sy]; + } + } + state.facing.swap(shifted); + } + state.pending.erase(state.pending.begin(), + state.pending.begin() + std::ptrdiff_t(landed_count)); + state.pending_frames = 0; + state.pending_age = 0; + // The scroll is accounted for; the settle window must not block the + // rebased pass. + state.suppress_frames = 0; + landed_shift = landed; + translated = true; + } else if (shift_match_ratio(input, 0, 0) >= min_scroll_match_ratio && + ++state.pending_age <= max_pending_age_frames) { + // The buffers have not moved yet, so the scroll is still in flight. + state.pending_frames = 0; + } else if (++state.pending_frames > max_unmatched_scroll_redraws) { + // The shift never showed up recognizably: fall back to the safe reset. + abandon_pending(state); + // The delta was never identified, so the grid cannot be translated. + reset_facing(state); + // It may yet land, so do not resume detection on the very next redraw. + state.suppress_frames = scroll_settle_redraws; + } + } + + const bool suppress = !buffers_advanced || crossed_views || !state.pending.empty() || + state.suppress_frames > 0; + // The countdown measures redraws, not frames, so a repeated viewport must + // not spend it. + if (buffers_advanced && state.suppress_frames > 0) + --state.suppress_frames; + + // On the landing frame `previous` is still framed on the pre-scroll view. + // Rebasing it by the landed delta keeps a creature that walked during the + // scroll. + auto previous_layers = input.previous; + std::vector> rebased_previous; + if (translated && !suppress) { + rebased_previous.resize(input.previous.size()); + for (size_t layer = 0; layer < input.previous.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + rebased_previous[layer].assign( + size_t(input.dimensions.x) * size_t(input.dimensions.y), 0); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + const int32_t sx = x + landed_shift.x; + if (sx < 0 || sx >= input.dimensions.x) + continue; + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t sy = y + landed_shift.y; + if (sy < 0 || sy >= input.dimensions.y) + continue; + rebased_previous[layer][x * input.dimensions.y + y] = + input.previous[layer][sx * input.dimensions.y + sy]; + } + } + previous_layers[layer] = rebased_previous[layer].data(); + } + } + if (!suppress) { + const int32_t tile_count = input.dimensions.x * input.dimensions.y; + std::vector claimed_sources(tile_count); + const size_t existing_movement_count = state.movements.size(); + // A chained movement's source may already have been rewritten this frame. + const std::vector facing_at_frame_start = state.facing; + // Source clears are deferred until every movement this frame is + // registered. A source can be another movement's target in the same frame + // -- a chain. + std::vector facing_target_written; + std::vector pending_facing_source_clears; + if (state.facing.size() == size_t(input.dimensions.x) * size_t(input.dimensions.y)) + facing_target_written.assign(state.facing.size(), 0); + for (size_t layer = 0; layer < input.current.size(); ++layer) { + if (!visual_layer_tracks_own_movement(static_cast(layer))) + continue; + std::fill(claimed_sources.begin(), claimed_sources.end(), 0); + const int32_t *current = input.current[layer]; + const int32_t *previous = previous_layers[layer]; + const auto shared_delta = + static_cast(layer) == viewport_visual_layer::center + ? shared_movement_delta( + std::span(current, size_t(tile_count)), + std::span(previous, size_t(tile_count)), input.dimensions.x, + input.dimensions.y) + : df::coord2d(0, 0); + for (int32_t x = 0; x < input.dimensions.x; ++x) { + for (int32_t y = 0; y < input.dimensions.y; ++y) { + const int32_t target = x * input.dimensions.y + y; + const int32_t texpos = current[target]; + if (texpos == 0) + continue; + if (static_cast(layer) == + viewport_visual_layer::item && + previous_layers[static_cast(viewport_visual_layer::center)] + [target] != 0) + continue; + + int32_t source = -1; + int32_t candidate_count = 0; + if (shared_delta.x != 0 || shared_delta.y != 0) { + const int32_t source_x = x + shared_delta.x; + const int32_t source_y = y + shared_delta.y; + if (source_x >= 0 && source_x < input.dimensions.x && source_y >= 0 && + source_y < input.dimensions.y) { + const int32_t candidate = source_x * input.dimensions.y + source_y; + if (!claimed_sources[candidate] && previous[target] != texpos && + previous[candidate] == texpos) { + source = candidate; + candidate_count = 1; + } + } + } + // Otherwise require a unique same-sprite move between empty cells. + if (candidate_count == 0 && previous[target] == 0) { + for (int32_t dx = -1; dx <= 1; ++dx) { + for (int32_t dy = -1; dy <= 1; ++dy) { + if (dx == 0 && dy == 0) + continue; + const int32_t source_x = x + dx; + const int32_t source_y = y + dy; + if (source_x < 0 || source_x >= input.dimensions.x || + source_y < 0 || source_y >= input.dimensions.y) + continue; + const int32_t candidate = + source_x * input.dimensions.y + source_y; + if (!claimed_sources[candidate] && + visual_layer_matches( + static_cast(layer), texpos, + previous[candidate]) && + current[candidate] == 0) { + source = candidate; + ++candidate_count; + } + } + } + } + if (candidate_count != 1) + continue; + + claimed_sources[source] = 1; + float visual_source_x = float(source / input.dimensions.y); + float visual_source_y = float(source % input.dimensions.y); + for (size_t i = 0; i < existing_movement_count; ++i) { + const movementst &movement = state.movements[i]; + if (movement.layer != static_cast(layer) || + movement.target.x != visual_source_x || + movement.target.y != visual_source_y) + continue; + const float progress = movement_progress(movement.start_time_ms); + visual_source_x = movement.source.x + + (movement.target.x - movement.source.x) * progress; + visual_source_y = movement.source.y + + (movement.target.y - movement.source.y) * progress; + break; + } + state.movements.push_back({static_cast(layer), + texpos, + {visual_source_x, visual_source_y}, + df::coord2d(x, y), + frame_time_ms}); + if (static_cast(layer) == + viewport_visual_layer::center && + state.facing.size() == + size_t(input.dimensions.x) * size_t(input.dimensions.y) && + !facing_at_frame_start.empty() && + facing_at_frame_start.size() == state.facing.size()) { + const int32_t source_tile_x = source / input.dimensions.y; + const int32_t target_index = x * input.dimensions.y + y; + state.facing[target_index] = int8_t(facing_after_move( + x - source_tile_x, + static_cast(facing_at_frame_start[source]))); + facing_target_written[size_t(target_index)] = 1; + pending_facing_source_clears.push_back(source); + } + } + } + } + // A source vacates its tile only if no movement this frame claimed it as + // a target. + if (!facing_target_written.empty()) { + for (int32_t pending_source : pending_facing_source_clears) { + if (!facing_target_written[size_t(pending_source)]) + state.facing[size_t(pending_source)] = int8_t(native_sprite_facing); + } + } + } + state.movements.erase( + std::remove_if( + state.movements.begin(), state.movements.end(), + [&](const movementst &movement) { + const size_t layer = static_cast(movement.layer); + const int32_t target = + movement.target.x * input.dimensions.y + movement.target.y; + const int32_t current = input.current[layer][target]; + return frame_time_ms - movement.start_time_ms >= movement_duration_ms || + current == 0 || + !visual_layer_matches(movement.layer, current, movement.texpos); + }), + state.movements.end()); + // has_mirrored is recomputed here rather than maintained at every write + // site. + if (state.facing.size() == size_t(input.dimensions.x) * size_t(input.dimensions.y)) { + const int32_t *center_current = + input.current[static_cast(viewport_visual_layer::center)]; + bool any_mirrored = false; + for (size_t i = 0; i < state.facing.size(); ++i) { + if (center_current[i] == 0) + state.facing[i] = int8_t(native_sprite_facing); + else if (state.facing[i] != int8_t(native_sprite_facing)) + any_mirrored = true; + } + state.has_mirrored = any_mirrored; + } + if (!state.movements.empty()) + force_full_redraw = true; + } + + void end_frame() { + viewports.erase( + std::remove_if(viewports.begin(), viewports.end(), + [](const viewport_animationst &state) { return !state.seen; }), + viewports.end()); + for (const viewport_animationst &state : viewports) { + if (!state.movements.empty()) + force_full_redraw = true; + } + } + + uint32_t get_frame_time_ms() const { return frame_time_ms; } + + uint32_t get_frame_delta_ms() const { return frame_delta_ms; } + + visual_facingst get_facing(const void *viewport, df::coord2d pos) const { + for (const viewport_animationst &state : viewports) { + if (state.viewport != viewport) + continue; + if (pos.x < 0 || pos.x >= state.dimensions.x || pos.y < 0 || + pos.y >= state.dimensions.y) + break; + const size_t index = size_t(pos.x) * size_t(state.dimensions.y) + size_t(pos.y); + if (index >= state.facing.size()) + break; + return static_cast(state.facing[index]); + } + return native_sprite_facing; + } + + visual_facingst get_facing(const void *viewport, int32_t x, int32_t y) const { + return get_facing(viewport, df::coord2d(x, y)); + } + + bool has_mirrored_facing(const void *viewport) const { + for (const viewport_animationst &state : viewports) { + if (state.viewport != viewport) + continue; + return state.has_mirrored; + } + return false; + } + + bool requires_full_redraw() const { return force_full_redraw; } + + bool has_active_movement(const void *viewport) const { + for (const viewport_animationst &state : viewports) { + if (state.viewport == viewport) + return !state.movements.empty(); + } + return false; + } + + visual_movement_renderst get_movement(const void *viewport, viewport_visual_layer layer, + int32_t target_x, int32_t target_y) const { + for (const viewport_animationst &state : viewports) { + if (state.viewport != viewport) + continue; + const movementst *companion = nullptr; + bool ambiguous = false; + for (const movementst &movement : state.movements) { + if (movement.layer == layer && movement.target.x == target_x && + movement.target.y == target_y) { + return {true, movement.source, movement_progress(movement.start_time_ms)}; + } + if (layer == viewport_visual_layer::vehicle || + layer == viewport_visual_layer::center || + movement.layer != viewport_visual_layer::center || + std::abs(movement.target.x - target_x) > 1 || + std::abs(movement.target.y - target_y) > 1) + continue; + if (companion != nullptr && (companion->source.x - companion->target.x != + movement.source.x - movement.target.x || + companion->source.y - companion->target.y != + movement.source.y - movement.target.y || + companion->start_time_ms != movement.start_time_ms)) + ambiguous = true; + else if (companion == nullptr) + companion = &movement; + } + if (ambiguous) + return {}; + if (companion != nullptr) + return {true, + {target_x + companion->source.x - companion->target.x, + target_y + companion->source.y - companion->target.y}, + movement_progress(companion->start_time_ms), + true}; + break; + } + return {}; + } +}; + +#endif // SMOOTH_MOVEMENT_VISUAL_ANIMATION_H