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..d1e3edb0ea --- /dev/null +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -0,0 +1,955 @@ +// SPDX-License-Identifier: Zlib + +#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; + +constexpr uint32_t fire_bits = 0x70000000U; + +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), + .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 int32_t pan_x = window_x ? *window_x : 0; + const int32_t pan_y = window_y ? *window_y : 0; + if (!has_pan_context || previous_pan.x != pan_x || previous_pan.y != pan_y) + previous_coverage.clear(); + previous_pan.x = pan_x; + previous_pan.y = pan_y; + 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_pixel(int32_t tile, int32_t origin, int32_t zoom) { + return zoom == 128 ? 32 * tile + origin : (zoom * 32 * tile) / 128 + origin; +} + +bool inside_clip(const df::graphic_viewportst *vp, int32_t x, int32_t y) { + return x >= vp->clipx[0] && x <= vp->clipx[1] && y >= vp->clipy[0] && y <= vp->clipy[1]; +} + +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; + point2dst 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, int32_t x, + int32_t y) { + if (!interface_pass_readable(vp)) + return; + const int32_t index = x * vp->dim_y + y; + const auto redraw = [&] { renderer->update_viewport_tile(vp, x, 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 int32_t target_x = tile_pixel(proxy.target.x, renderer->origin_x, zoom); + const int32_t target_y = tile_pixel(proxy.target.y, renderer->origin_y, zoom); + const float tile_size = float(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); + 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) + continue; + if (!movement_enabled) + continue; + const auto movement = animation_manager.get_movement( + vp, static_cast(layer), x, y); + if (!movement.active) + continue; + 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) { + if (anchor.layer == viewport_visual_layer::center && + std::abs(anchor.target.x - x) <= 1 && + std::abs(anchor.target.y - y) <= 1 && + anchor.source.x - anchor.target.x == movement.source.x - x && + anchor.source.y - anchor.target.y == movement.source.y - y && + 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.x - anchor.target.x == movement.source.x - x && + anchor.source.y - anchor.target.y == movement.source.y - y && + 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 bool mirrored = + mirror_eligible && animation_manager.get_facing( + vp, x + mirror_descriptor.anchor_offset.x, + y + mirror_descriptor.anchor_offset.y) != 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; + for (int32_t coverage_x = int32_t(std::floor(std::min(proxy.source.x, float(x)))); + coverage_x <= int32_t(std::ceil(std::max(proxy.source.x, float(x)))); + ++coverage_x) { + for (int32_t coverage_y = + int32_t(std::floor(std::min(proxy.source.y, float(y)))); + coverage_y <= int32_t(std::ceil(std::max(proxy.source.y, float(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) { + if (animation_manager.get_facing(vp, anchor_x, anchor_y) == 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()); + 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 (int32_t lower = 7; lower >= 0; --lower) { + 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 auto &[x, y] : coverage) { + if (!inside_clip(vp, x, y)) + continue; + redraw_viewport_tile(renderer, viewport, x, 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 auto &[x, y] : coverage) { + if (inside_clip(viewport.viewport, x, y)) + draw_interface_only(renderer, viewport.viewport, x, y); + } + } +} + +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 = zoom == 128 ? 32 : std::max(1, zoom * 32 / 128); + + 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.x = 0; + previous_pan.y = 0; + 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..6e7b702ca5 --- /dev/null +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -0,0 +1,715 @@ +// SPDX-License-Identifier: Zlib + +#include +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include +#include + +#include "visual_animation.h" + +namespace { + +viewport_visual_animation_inputst make_input(const void *viewport, int32_t dimension, + const int32_t *empty) { + viewport_visual_animation_inputst input; + input.viewport = viewport; + input.dimensions = df::coord2d(dimension, dimension); + input.context_revision = 1; + input.current.fill(empty); + input.previous.fill(empty); + return input; +} + +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; +} + +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); + + assert(mirrored_tile_x(5, 5) == 5); // anchor reflects to itself + assert(mirrored_tile_x(6, 5) == 4); // right spill -> left + assert(mirrored_tile_x(4, 5) == 6); // left spill -> right + // The formula is a general reflection, so it holds for offsets no layer can + // express. + assert(mirrored_tile_x(8, 5) == 2); + // 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(6, 5), 5) == 6); + assert(mirrored_tile_x(mirrored_tile_x(8, 5), 5) == 8); + + { + constexpr int32_t dim = 4; + 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] = 77; + west_after[1 * dim + 2] = 77; // 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, 1000); + assert(!manager.has_active_movement(viewport)); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, 1016); + 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, 1000); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, 1016); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + set_layer(input, viewport_visual_layer::center, before, west_after); + run_frame(manager, input, 1032); + 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, 1000); + 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, 1016); + 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, 1032); + 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, 1048); + 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, 1000); + set_layer(input, viewport_visual_layer::center, west_after, before); + run_frame(manager, input, 1016); + assert(manager.get_facing(viewport, 1, 2) == visual_facingst::west); + int32_t up[dim * dim] = {}; + up[1 * dim + 1] = 77; // north: (1,2) -> (1,1), no horizontal component + set_layer(input, viewport_visual_layer::center, up, west_after); + run_frame(manager, input, 1032); + 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, 1000); + 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(1000); + 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(1016); + 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; + 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] = 77; // (2,2) + north_east[3 * diag_dim + 1] = 77; // (2,2) -> (3,1): dx +1, dy -1 + south_west[2 * diag_dim + 2] = 77; // (3,1) -> (2,2): dx -1, dy +1 + south_east[3 * diag_dim + 3] = 77; // (2,2) -> (3,3): dx +1, dy +1 + north_west[2 * diag_dim + 2] = 77; // (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, 1000); + 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, 1016); + 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, 1032); + 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, 1048); + 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, 1064); + 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. + { + constexpr int32_t pan_dim = 4; + int32_t pan_empty[pan_dim * pan_dim] = {}; + int32_t at_one[pan_dim * pan_dim] = {}; + int32_t at_two[pan_dim * pan_dim] = {}; + int32_t unmatched_a[pan_dim * pan_dim] = {}; + int32_t unmatched_b[pan_dim * pan_dim] = {}; + const int pan_token = 0; + const void *pan_viewport = &pan_token; + + at_one[1 * pan_dim + 1] = 77; + at_two[2 * pan_dim + 1] = 77; // steps east, x 1 -> 2, so it faces east + unmatched_a[2 * pan_dim + 1] = 78; + unmatched_b[2 * pan_dim + 1] = 79; + + // LANDED: the shift is recognized, so facing follows the buffers. + { + visual_animation_managerst landed; + auto input = make_input(pan_viewport, pan_dim, 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, 1016); + 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.x = 1; + set_layer(input, viewport_visual_layer::center, at_two, at_two); + run_frame(landed, input, 1032); + 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, 1048); + 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_dim, 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, 2016); + 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.x = 1; + for (int32_t frame = 0; frame < 4; ++frame) { + set_layer(input, viewport_visual_layer::center, at_two, + frame % 2 == 0 ? unmatched_a : unmatched_b); + run_frame(abandoned, input, 2032 + uint32_t(frame) * 16); + // 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, 2096); + 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; + 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] = 77; // A at (1,1) + frame_a[2 * chase_dim + 2] = 88; // B at (2,2), stationary, keeps the default facing + frame_b[2 * chase_dim + 1] = 77; // A steps east: (1,1) -> (2,1), picks up an east facing + frame_b[2 * chase_dim + 2] = 88; // 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] = 77; // A: (2,1) -> (2,2) + frame_c[2 * chase_dim + 3] = 88; // 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; + 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] = 77; // D at (1,1) + frame_a[2 * gap_dim + 2] = 88; // E at (2,2), stationary companion + frame_b[2 * gap_dim + 1] = + 77; // D steps east: (1,1) -> (2,1), facing differs from the default + frame_b[2 * gap_dim + 2] = 88; // 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] = 77; // D: (2,1) -> (2,2) + frame_c[2 * gap_dim + 3] = 88; // E: (2,2) -> (2,3) + frame_c[2 * gap_dim + 1] = 55; // 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); + } + + assert(animation_progress(100, 0, 100) == 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); + + std::array empty{}; + std::array current{}; + std::array previous{}; + const void *viewport = reinterpret_cast(uintptr_t(1)); + auto input = make_input(viewport, 3, empty.data()); + set_layer(input, viewport_visual_layer::center, current.data(), previous.data()); + + visual_animation_managerst movement; + run_frame(movement, input, 1990); + assert(!movement.requires_full_redraw()); + + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + run_frame(movement, input, 2000); + 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.data(), previous.data()); + run_frame(movement, input, 2050); + render = movement.get_movement(viewport, viewport_visual_layer::center, 1, 1); + assert(render.active); + assert(render.progress == 0.5f); + + run_frame(movement, input, 2100); + assert(!movement.get_movement(viewport, viewport_visual_layer::center, 1, 1).active); + assert(movement.requires_full_redraw()); + + run_frame(movement, input, 2120); + 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.data(), previous.data()); + 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(visual_moved_between_tiles(layer, current.data(), previous.data(), 0 * 3 + 1, + 1 * 3 + 1)); + previous[1 * 3 + 1] = 50; + assert(!visual_moved_between_tiles(layer, current.data(), previous.data(), 0 * 3 + 1, + 1 * 3 + 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. + std::array pan_current{}; + std::array pan_previous{}; + std::array pan_empty{}; + auto pan_input = make_input(viewport, 3, pan_empty.data()); + set_layer(pan_input, viewport_visual_layer::center, pan_current.data(), pan_previous.data()); + + // 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.x = 1; // 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.x = 0; + run_frame(panner, pan_input, 5990); + pan_previous[0 * 3 + 1] = 42; // creature steps (0,1) -> (1,1) + pan_current[1 * 3 + 1] = 42; + 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.x = 1; // 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.x = 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.x = 1; + 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.x = 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. + std::array status_current{}; + std::array status_previous{}; + current.fill(0); + previous.fill(0); + input.context_revision = 1; + input.current.fill(empty.data()); + input.previous.fill(empty.data()); + set_layer(input, viewport_visual_layer::center, current.data(), previous.data()); + set_layer(input, viewport_visual_layer::item, status_current.data(), status_previous.data()); + set_layer(input, viewport_visual_layer::designation, status_current.data(), + status_previous.data()); + visual_animation_managerst companion; + run_frame(companion, input, 8990); + previous[0 * 3 + 1] = 42; + current[1 * 3 + 1] = 42; + status_previous[0 * 3 + 0] = 90; + status_current[1 * 3 + 0] = 91; + assert(visual_moved_between_tiles(viewport_visual_layer::designation, status_current.data(), + status_previous.data(), 0 * 3 + 0, 1 * 3 + 0)); + status_previous[0 * 3 + 0] = 0; + assert(visual_moved_between_tiles(viewport_visual_layer::designation, status_current.data(), + status_previous.data(), 0 * 3 + 0, 1 * 3 + 0)); + assert(!visual_moved_between_tiles(viewport_visual_layer::item, status_current.data(), + status_previous.data(), 0 * 3 + 0, 1 * 3 + 0)); + status_previous[1 * 3 + 0] = 80; + assert(!visual_moved_between_tiles(viewport_visual_layer::designation, status_current.data(), + status_previous.data(), 0 * 3 + 0, 1 * 3 + 0)); + status_previous[0 * 3 + 0] = 90; + status_previous[1 * 3 + 0] = 0; + run_frame(companion, input, 9000); + 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, 9050); + 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); + input.current.fill(empty.data()); + input.previous.fill(empty.data()); + set_layer(input, viewport_visual_layer::item, current.data(), previous.data()); + 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); + input.current.fill(empty.data()); + input.previous.fill(empty.data()); + set_layer(input, viewport_visual_layer::vehicle, current.data(), previous.data()); + visual_animation_managerst vehicle; + run_frame(vehicle, input, 11990); + previous[0 * 3 + 1] = 77; + current[1 * 3 + 1] = 78; + run_frame(vehicle, input, 12000); + assert(vehicle.get_movement(viewport, viewport_visual_layer::vehicle, 1, 1).active); + previous = current; + current[1 * 3 + 1] = 79; + run_frame(vehicle, input, 12050); + 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[2 * 3 + 1] = 80; + run_frame(vehicle, input, 12060); + 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..9577dfcabf --- /dev/null +++ b/plugins/smooth-movement/visual_animation.h @@ -0,0 +1,865 @@ +// SPDX-License-Identifier: Zlib + +#ifndef SMOOTH_MOVEMENT_VISUAL_ANIMATION_H +#define SMOOTH_MOVEMENT_VISUAL_ANIMATION_H + +#include +#include +#include +#include +#include +#include + +#include "DataDefs.h" + +template struct point2dst { + T x = 0; + T y = 0; +}; + +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; + point2dst 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 = point2dst{-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 = point2dst{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 = point2dst{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 = point2dst{-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 = point2dst{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 = point2dst{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 = point2dst{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 = point2dst{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 = point2dst{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 void *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; + point2dst 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; + point2dst source; + df::coord2d target; + uint32_t start_time_ms; + }; + + struct viewport_animationst { + const void *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. + uint64_t buffer_signature = 0; + bool has_buffer_signature = false; + // 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; + + 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. + static constexpr int32_t max_pending_age_frames = 120; + + 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(const int32_t *current, const int32_t *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.x != input.dimensions.x || state.dimensions.y != input.dimensions.y); + 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.x != input.pan.x || state.pan.y != input.pan.y)) { + 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.x - state.pan.x, input.pan.y - state.pan.y); + state.pending_frames = 0; + state.suppress_frames = 2; + } + state.context_revision = input.context_revision; + state.dimensions.x = input.dimensions.x; + state.dimensions.y = input.dimensions.y; + state.has_context = true; + state.pan.x = input.pan.x; + state.pan.y = input.pan.y; + 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.has_buffer_signature || state.buffer_signature != signature; + state.buffer_signature = signature; + state.has_buffer_signature = true; + + 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.x += state.pending[count - 1].x; + shift.y += state.pending[count - 1].y; + // A prefix netting to zero is indistinguishable from "nothing landed + // yet". Accepting it would retire shifts the buffers have still to + // apply. + if (shift.x == 0 && shift.y == 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 >= 0.5) { + 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) >= 0.5 && + ++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 > 4) { + // 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 = 2; + } + } + + 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(current, previous, 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, int32_t x, int32_t y) const { + for (const viewport_animationst &state : viewports) { + if (state.viewport != viewport) + continue; + if (x < 0 || x >= state.dimensions.x || y < 0 || y >= state.dimensions.y) + break; + const size_t index = size_t(x) * size_t(state.dimensions.y) + size_t(y); + if (index >= state.facing.size()) + break; + return static_cast(state.facing[index]); + } + return native_sprite_facing; + } + + 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