From ecfb2e6ce5ea7b950dfd294b0eceb5f16e743610 Mon Sep 17 00:00:00 2001 From: notliad Date: Mon, 10 Aug 2026 10:44:46 -0300 Subject: [PATCH 1/6] add smooth-movement plugin --- docs/about/Authors.rst | 3 + docs/changelog.txt | 1 + docs/plugins/smooth-movement.rst | 67 + plugins/CMakeLists.txt | 1 + plugins/smooth-movement/CMakeLists.txt | 3 + plugins/smooth-movement/smooth-movement.cpp | 1314 +++++++++++++++++ .../test_visual_animation_manager.cpp | 694 +++++++++ plugins/smooth-movement/visual_animation.h | 793 ++++++++++ 8 files changed, 2876 insertions(+) create mode 100644 docs/plugins/smooth-movement.rst create mode 100644 plugins/smooth-movement/CMakeLists.txt create mode 100644 plugins/smooth-movement/smooth-movement.cpp create mode 100644 plugins/smooth-movement/test_visual_animation_manager.cpp create mode 100644 plugins/smooth-movement/visual_animation.h 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..2cdc579a3e --- /dev/null +++ b/docs/plugins/smooth-movement.rst @@ -0,0 +1,67 @@ +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 camera on|off|reset| + 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 camera on`` + Enable smooth camera scrolling and pixel-precise middle-mouse dragging. + +``smooth-movement camera 0.25 -0.25`` + Enable the free camera and offset the view by a quarter tile east and + north of the tile grid. + +``smooth-movement camera reset`` + Return the free camera to the tile grid without disabling it. + +``smooth-movement zlevel on`` + Also animate movement on visible z-levels below the main level. + +Settings +-------- + +``camera`` (default: off) + Enable or disable smooth camera scrolling and free-camera dragging. The + ``reset`` argument clears the persistent sub-tile offset. Two numeric + arguments set the horizontal and vertical offsets in tiles; each must be + between -0.99 and 0.99. + +``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..aadaf44ad7 --- /dev/null +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -0,0 +1,1314 @@ +// SPDX-License-Identifier: MIT + +#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/enabler.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(enabler); +REQUIRE_GLOBAL(gps); +REQUIRE_GLOBAL(window_x); +REQUIRE_GLOBAL(window_y); +REQUIRE_GLOBAL(window_z); + +namespace { + +// 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_RenderSetClipRect) render_set_clip_rect = nullptr; +decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; +decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; + +visual_animation_managerst animation_manager; +std::set> previous_coverage; +uint64_t visual_context_revision = 0; +const void *previous_viewport = nullptr; +std::array previous_view_signature{}; +bool has_view_signature = 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. +int32_t previous_pan_x = 0; +int32_t previous_pan_y = 0; +bool has_pan_context = false; +bool flip_enabled = false; +bool zlevel_enabled = false; + +// --- free camera ------------------------------------------------------------------------------- +// The camera is visually unbound from the tile grid. Two layered offsets: +// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by pixel-perfect +// middle-mouse drag panning (the view rests wherever released, mid-tile or not) +// and by the `smooth-movement camera ` console command. Survives zoom +// and z-level changes. Kept in [-0.5,0.5] by normalization: whole-tile parts are +// folded into window_x/window_y (a plain UI scroll write -- NEVER the viewport +// dims, which crash DF; the sub-tile strip this leaves at one screen edge has no +// buffer data and stays black). +// transient -- the decaying scroll glide from before, in pixels, layered on top. +// Render offset = transient + rest*tile. window_x/window_y remain the game's own tile camera. +bool camera_enabled = false; // OFF by default: plain `enable smooth-movement` + // keeps upstream behavior (creature interpolation + // only); `smooth-movement camera on` opts in. +constexpr int32_t camera_max_glide_tiles = 3; // per-jump: farther than this snaps instantly +constexpr double camera_tau_ms = 35.0; // transient catch-up (~95% done after 100ms) +double transient_x = 0.0; // decaying glide offset, pixels +double transient_y = 0.0; +double rest_x = 0.0; // persistent free-camera offset, tiles +double rest_y = 0.0; // (positive = view sits WEST/NORTH of window) +int32_t camera_pending_dx = 0; // scroll delta announced but not yet in the buffers +int32_t camera_pending_dy = 0; +int32_t camera_pending_frames = 0; +int32_t self_scroll_x = 0; // window deltas WE wrote: visual no-ops when landing +int32_t self_scroll_y = 0; +bool drag_active = false; +double drag_anchor_vx = 0.0; // visual camera at drag start, tiles +double drag_anchor_vy = 0.0; +int32_t drag_anchor_mx = 0; // precise mouse at drag start, pixels +int32_t drag_anchor_my = 0; +bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw +int32_t camera_prev_wx = 0; // window-scroll observation baseline +int32_t camera_prev_wy = 0; +bool camera_has_prev = false; + +double tile_px(const df::renderer_2d_base *renderer) { + const int32_t zoom = renderer->viewport_zoom_factor; + return double(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); +} + +// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, or -1 when there +// is nothing to compare (empty background). +double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int32_t dwy) { + int32_t considered = 0; + int32_t matches = 0; + for (int32_t x = 0; x < vp->dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= vp->dim_x) + continue; + for (int32_t y = 0; y < vp->dim_y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= vp->dim_y) + continue; + const int32_t cur = vp->screentexpos_background[x * vp->dim_y + y]; + if (cur == 0) + continue; + ++considered; + if (vp->screentexpos_background_old[sx * vp->dim_y + sy] == cur) + ++matches; + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); +} + +// Cancel everything except the persistent rest offset (the camera keeps its sub-tile position +// across zoom/z/resize; only the in-flight animation state is unfollowable). +void clear_camera_pending() { + camera_pending_dx = 0; + camera_pending_dy = 0; + camera_pending_frames = 0; + self_scroll_x = 0; + self_scroll_y = 0; +} + +void cancel_camera_transients() { + transient_x = 0.0; + transient_y = 0.0; + clear_camera_pending(); + drag_active = false; +} + +void set_camera_enabled(bool enable) { + if (camera_enabled == enable) + return; + camera_enabled = enable; + cancel_camera_transients(); + rest_x = 0.0; + rest_y = 0.0; + camera_has_prev = false; // fresh observation baseline; no phantom scroll on re-enable + // camera_was_offset stays: the render path issues one cleanup redraw if we were mid-offset. +} + +// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal edge strip). The +// visual position is unchanged: the window write is attributed via self_scroll when it lands. +void normalize_rest() { + const int32_t kx = int32_t(-std::llround(rest_x)); + const int32_t ky = int32_t(-std::llround(rest_y)); + if (kx != 0 && window_x != nullptr && *window_x + kx >= 0) { + *window_x += kx; + self_scroll_x += kx; + } + if (ky != 0 && window_y != nullptr && *window_y + ky >= 0) { + *window_y += ky; + self_scroll_y += ky; + } +} + +// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization writes are visual +// no-ops (they move into rest); the remainder is a real scroll and glides -- unless a drag is +// driving the position directly, in which case it folds into rest wholesale. +void attribute_landed(int32_t ax, int32_t ay, double tile) { + int32_t sx = 0; + if (self_scroll_x != 0 && (self_scroll_x > 0) == (ax > 0) && ax != 0) + sx = (std::abs(self_scroll_x) <= std::abs(ax)) ? self_scroll_x : ax; + int32_t sy = 0; + if (self_scroll_y != 0 && (self_scroll_y > 0) == (ay > 0) && ay != 0) + sy = (std::abs(self_scroll_y) <= std::abs(ay)) ? self_scroll_y : ay; + self_scroll_x -= sx; + self_scroll_y -= sy; + rest_x += sx; + rest_y += sy; + const int32_t gx = ax - sx; + const int32_t gy = ay - sy; + if (drag_active) { + rest_x += gx; + rest_y += gy; + } else { + transient_x += gx * tile; + transient_y += gy * tile; + const double cap = tile * (camera_max_glide_tiles + 0.5); + transient_x = std::clamp(transient_x, -cap, cap); + transient_y = std::clamp(transient_y, -cap, cap); + } +} + +// Per-frame camera bookkeeping: observe window scrolls, attribute them when the buffers apply +// them (glide vs our own normalization writes), drive the drag, decay the transient. +void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst *vp, + uint32_t delta_ms) { + if (!camera_enabled) + return; + const double tile = tile_px(renderer); + const int32_t wx = window_x ? *window_x : 0; + const int32_t wy = window_y ? *window_y : 0; + if (camera_has_prev && (wx != camera_prev_wx || wy != camera_prev_wy)) { + const int32_t dx = wx - camera_prev_wx; + const int32_t dy = wy - camera_prev_wy; + if ((std::abs(dx) > camera_max_glide_tiles || std::abs(dy) > camera_max_glide_tiles) && + !drag_active) + cancel_camera_transients(); // teleport-like jump (recenter/minimap): snap + else { + camera_pending_dx += dx; + camera_pending_dy += dy; + camera_pending_frames = 0; + } + } + camera_prev_wx = wx; + camera_prev_wy = wy; + camera_has_prev = true; + + if (camera_pending_dx != 0 || camera_pending_dy != 0) { + if (std::abs(camera_pending_dx) > 6 || std::abs(camera_pending_dy) > 6) { + // Scrolling far outran detection: snap (keep rest, drop the animation debt). + transient_x = 0.0; + transient_y = 0.0; + clear_camera_pending(); + } else { + // Fast scrolling applies the pending delta PIECEMEAL: the buffers may hold +1 of a + // pending +3 this frame. Testing only the total made landings miss, time out, and + // snap -- the fast-scroll jitter. Instead, find the LARGEST applied prefix of the + // pending scroll and attribute just that; the rest keeps pending. Ties between + // qualifying shifts only happen on uniform terrain, where mistiming is invisible. + const int32_t stepx = (camera_pending_dx > 0) - (camera_pending_dx < 0); + const int32_t stepy = (camera_pending_dy > 0) - (camera_pending_dy < 0); + int32_t best_ax = 0, best_ay = 0, best_mag = -1; + double best_score = -1.0; + bool no_data = false; + for (int32_t ix = 0; ix <= std::abs(camera_pending_dx); ++ix) { + for (int32_t iy = 0; iy <= std::abs(camera_pending_dy); ++iy) { + const double score = background_match_ratio(vp, ix * stepx, iy * stepy); + if (score < 0.0) { + no_data = true; + break; + } + const int32_t mag = ix + iy; + if (score >= 0.6 && + (mag > best_mag || (mag == best_mag && score > best_score))) { + best_mag = mag; + best_score = score; + best_ax = ix * stepx; + best_ay = iy * stepy; + } + } + if (no_data) + break; + } + if (no_data) { + // Nothing to compare against (empty background): give up on attribution. + clear_camera_pending(); + } else if (best_mag > 0) { + attribute_landed(best_ax, best_ay, tile); + camera_pending_dx -= best_ax; + camera_pending_dy -= best_ay; + camera_pending_frames = 0; + } else if (best_mag == 0) { + // Content demonstrably hasn't moved yet: keep waiting, no timeout pressure. + camera_pending_frames = 0; + } else if (++camera_pending_frames > 4) { + // Neither static nor any prefix recognizable (heavy simultaneous change): + // drop the debt without touching the in-flight glide. + clear_camera_pending(); + } + } + } + + // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and rests where + // released. DF's own drag still moves window in tile steps; rest carries the remainder. + // Positions are tracked against the CONTENT window (window minus unlanded jumps) so the + // buffer lag never causes a visible stutter. + const bool mbut = enabler != nullptr && enabler->mouse_mbut; + const double content_wx = double(wx - camera_pending_dx); + const double content_wy = double(wy - camera_pending_dy); + if (mbut && !drag_active && gps != nullptr) { + drag_active = true; + drag_anchor_vx = content_wx - rest_x - transient_x / tile; + drag_anchor_vy = content_wy - rest_y - transient_y / tile; + drag_anchor_mx = gps->precise_mouse_x; + drag_anchor_my = gps->precise_mouse_y; + transient_x = 0.0; + transient_y = 0.0; + } + if (drag_active) { + if (!mbut) { + drag_active = false; + normalize_rest(); + } else if (gps != nullptr) { + double vx = drag_anchor_vx - double(gps->precise_mouse_x - drag_anchor_mx) / tile; + double vy = drag_anchor_vy - double(gps->precise_mouse_y - drag_anchor_my) / tile; + rest_x = content_wx - vx; + rest_y = content_wy - vy; + // If DF's own drag disagrees by more than a tile and a half, rebase on its view. + const double lim = 1.5; + if (rest_x < -lim || rest_x > lim || rest_y < -lim || rest_y > lim) { + rest_x = std::clamp(rest_x, -lim, lim); + rest_y = std::clamp(rest_y, -lim, lim); + drag_anchor_vx = + content_wx - rest_x + double(gps->precise_mouse_x - drag_anchor_mx) / tile; + drag_anchor_vy = + content_wy - rest_y + double(gps->precise_mouse_y - drag_anchor_my) / tile; + } + } + } + + if (transient_x != 0.0 || transient_y != 0.0) { + const double k = std::exp(-double(delta_ms) / camera_tau_ms); + transient_x *= k; + transient_y *= k; + if (std::abs(transient_x) < 0.5 && std::abs(transient_y) < 0.5) { + transient_x = 0.0; + transient_y = 0.0; + } + } +} + +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 std::array signature = {window_z ? *window_z : 0, + vp->dim_x, + vp->dim_y, + vp->clipx[0], + vp->clipx[1], + vp->clipy[0], + vp->clipy[1], + renderer->viewport_zoom_factor, + renderer->origin_x, + renderer->origin_y, + gps->dimx, + gps->dimy}; + const bool changed = + !has_view_signature || previous_viewport != vp || previous_view_signature != signature; + if (changed) { + ++visual_context_revision; + previous_coverage.clear(); + cancel_camera_transients(); + } + previous_viewport = vp; + previous_view_signature = signature; + has_view_signature = 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, + vp->dim_x, + vp->dim_y, + visual_context_revision, + visual_layers(const_viewport), + visual_layers(const_viewport, true), + 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; + float source_x; + float source_y; + int32_t target_x; + int32_t target_y; + int32_t texpos; + float progress; + SDL_Texture *texture; + bool mirrored = false; + int32_t mirror_shift = 0; + std::set> coverage; +}; + +using tile_coveragest = std::set>; + +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({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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = visual_layer_at_draw_order(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.center_x && + anchor.target_y == y + descriptor.center_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.center_x, + y + mirror_descriptor.center_y) != native_sprite_facing; + // The anchor's own layer has center_x 0, so it flips in place. + const int32_t mirror_shift = + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.center_x) - x : 0; + render_proxyst proxy = {static_cast(layer), + movement.source_x, + movement.source_y, + 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) { + std::set> mirrored_coverage; + for (const auto &tile : proxy.coverage) + mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); + for (const auto &tile : mirrored_coverage) { + if (!inside_clip(vp, tile.first, tile.second)) { + blocked = true; + break; + } + if (visual_render_group(proxy.layer) == visual_render_groupst::main && + has_fire(vp, tile.first, tile.second)) { + 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = + visual_layer_at_draw_order(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.center_x; + const int32_t y = anchor_y - descriptor.center_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), + 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; + update_camera(renderer, vp, animation_manager.get_frame_delta_ms()); + const double cam_tile = tile_px(renderer); + const int32_t glide_x = int32_t(std::lround(transient_x + rest_x * cam_tile)); + const int32_t glide_y = int32_t(std::lround(transient_y + rest_y * cam_tile)); + const bool glide = glide_x != 0 || glide_y != 0; + if (!glide && camera_was_offset) { + // The camera just re-joined the grid: one engine redraw replaces the last shifted frame. + camera_was_offset = false; + if (gps != nullptr) + ++gps->force_full_display_count; + } + if (glide) + camera_was_offset = true; + const bool animation_redraw = + zlevel_enabled ? animation_manager.requires_full_redraw() + : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); + if (!glide && !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); + + if (glide) { + // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the world (and + // the creature proxies, which read origin at draw time) renders between tiles. The engine + // already drew this frame at the snapped position; everything here overdraws it, clipped + // to the map rect so shifted tiles never spill over the UI. The uncovered strip on the + // trailing edge stays black until the glide lands. + const SDL_Rect map_rect = {tile_pixel(vp->clipx[0], renderer->origin_x, zoom), + tile_pixel(vp->clipy[0], renderer->origin_y, zoom), + tile_pixel(vp->clipx[1] + 1, renderer->origin_x, zoom) - + tile_pixel(vp->clipx[0], renderer->origin_x, zoom), + tile_pixel(vp->clipy[1] + 1, renderer->origin_y, zoom) - + tile_pixel(vp->clipy[0], renderer->origin_y, zoom)}; + render_set_clip_rect(sdl_renderer, &map_rect); + 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); + render_fill_rect(sdl_renderer, &map_rect); + set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); + + const int32_t saved_origin_x = renderer->origin_x; + const int32_t saved_origin_y = renderer->origin_y; + renderer->origin_x += glide_x; + renderer->origin_y += glide_y; + for (int32_t x = vp->clipx[0]; x <= vp->clipx[1]; ++x) { + for (int32_t y = vp->clipy[0]; y <= vp->clipy[1]; ++y) + redraw_world_tile(renderer, viewport_renders, coverage, x, y); + } + draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); + renderer->origin_x = saved_origin_x; + renderer->origin_y = saved_origin_y; + render_set_clip_rect(sdl_renderer, nullptr); + + // Everything was repainted; per-tile coverage bookkeeping restarts after the glide. + previous_coverage.clear(); + return; + } + + 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; + render_set_clip_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_RenderSetClipRect, render_set_clip_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_view_signature = {}; + has_view_signature = false; + previous_pan_x = 0; + previous_pan_y = 0; + has_pan_context = false; + cancel_camera_transients(); + rest_x = 0.0; + rest_y = 0.0; + camera_enabled = false; + camera_has_prev = false; + camera_was_offset = 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("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the grid)\n", + camera_enabled ? "on" : "off", -rest_x, -rest_y); + 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] == "camera") { + if (parameters.size() == 1) { + out.print("free camera: {}, offset {:.3f} {:.3f}\n", camera_enabled ? "on" : "off", + -rest_x, -rest_y); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "on") { + set_camera_enabled(true); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "off") { + set_camera_enabled(false); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "reset") { + rest_x = 0.0; + rest_y = 0.0; + return CR_OK; + } + if (parameters.size() == 3) { + try { + const double fx = std::stod(parameters[1]); + const double fy = std::stod(parameters[2]); + if (fx < -0.99 || fx > 0.99 || fy < -0.99 || fy > 0.99) { + out.printerr("offsets must be within -0.99..0.99 tiles\n"); + return CR_FAILURE; + } + // User-facing: positive = view sits east/south of the grid position. + set_camera_enabled(true); + rest_x = -fx; + rest_y = -fy; + normalize_rest(); + return CR_OK; + } catch (...) { + return CR_WRONG_USAGE; + } + } + return CR_WRONG_USAGE; + } + 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..293c96cf8e --- /dev/null +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -0,0 +1,694 @@ +// SPDX-License-Identifier: MIT + +#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.dim_x = dimension; + input.dim_y = 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); + // center_x is only ever -1, 0 or +1, so the real mirror shift is only ever -2, 0 or +2. + for (const auto &descriptor : visual_layer_descriptors) + assert(descriptor.center_x >= -1 && descriptor.center_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).center_x == -1); + assert(visual_layer_descriptor(viewport_visual_layer::left).center_x == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upright).center_x == -1 && + visual_layer_descriptor(viewport_visual_layer::upright).center_y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::up).center_x == 0 && + visual_layer_descriptor(viewport_visual_layer::up).center_y == 1); + assert(visual_layer_descriptor(viewport_visual_layer::upleft).center_x == 1 && + visual_layer_descriptor(viewport_visual_layer::upleft).center_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..5c0153eb5b --- /dev/null +++ b/plugins/smooth-movement/visual_animation.h @@ -0,0 +1,793 @@ +// SPDX-License-Identifier: MIT + +#ifndef SMOOTH_MOVEMENT_VISUAL_ANIMATION_H +#define SMOOTH_MOVEMENT_VISUAL_ANIMATION_H + +#include +#include +#include +#include +#include +#include + +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; + uint8_t draw_order; + int8_t center_x; + int8_t center_y; +}; + +constexpr std::array visual_layer_descriptors = { + visual_layer_descriptorst{viewport_visual_layer::right, visual_render_groupst::main, false, + false, 3, -1, 0}, + visual_layer_descriptorst{viewport_visual_layer::center, visual_render_groupst::main, true, + false, 0, 0, 0}, + visual_layer_descriptorst{viewport_visual_layer::left, visual_render_groupst::main, false, + false, 4, 1, 0}, + visual_layer_descriptorst{viewport_visual_layer::upright, visual_render_groupst::upper, false, + false, 5, -1, 1}, + visual_layer_descriptorst{viewport_visual_layer::up, visual_render_groupst::upper, false, false, + 6, 0, 1}, + visual_layer_descriptorst{viewport_visual_layer::upleft, visual_render_groupst::upper, false, + false, 7, 1, 1}, + visual_layer_descriptorst{viewport_visual_layer::vehicle, visual_render_groupst::vehicle, true, + true, 2, 0, 0}, + visual_layer_descriptorst{viewport_visual_layer::item, visual_render_groupst::item, true, false, + 1, 0, 0}, + visual_layer_descriptorst{viewport_visual_layer::designation, + visual_render_groupst::designation, false, true, 8, 0, 0}}; + +constexpr bool valid_visual_layer_descriptors() { + uint16_t draw_orders = 0; + for (size_t i = 0; i < visual_layer_descriptors.size(); ++i) { + const auto &descriptor = visual_layer_descriptors[i]; + if (static_cast(descriptor.layer) != i || + descriptor.draw_order >= visual_layer_descriptors.size() || + (draw_orders & (1U << descriptor.draw_order))) + return false; + draw_orders |= uint16_t(1U << descriptor.draw_order); + } + return true; +} + +static_assert(valid_visual_layer_descriptors()); + +constexpr const visual_layer_descriptorst &visual_layer_descriptor(viewport_visual_layer layer) { + return visual_layer_descriptors[static_cast(layer)]; +} + +constexpr viewport_visual_layer visual_layer_at_draw_order(uint8_t draw_order) { + for (const auto &descriptor : visual_layer_descriptors) + if (descriptor.draw_order == draw_order) + return descriptor.layer; + return viewport_visual_layer::count; +} + +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; + int32_t dim_x = 0; + int32_t dim_y = 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. + int32_t pan_x = 0; + int32_t pan_y = 0; + + bool valid() const { + if (viewport == nullptr || dim_x <= 0 || dim_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; + float source_x = 0.0f; + float source_y = 0.0f; + 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; +} + +// center_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; + float source_x; + float source_y; + int32_t target_x; + int32_t target_y; + uint32_t start_time_ms; + }; + + struct viewport_animationst { + const void *viewport = nullptr; + int32_t dim_x = 0; + int32_t dim_y = 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; + int32_t pan_x = 0; + int32_t pan_y = 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.dim_x * input.dim_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.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t texpos = current[x * input.dim_y + y]; + if (texpos == 0) + continue; + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + ++considered; + if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) + ++matches; + } + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); + } + + static std::array shared_movement_delta(const int32_t *current, + const int32_t *previous, int32_t dim_x, + int32_t dim_y) { + std::array best{}; + 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 = {dx, dy}; + ambiguous = false; + } else if (count == best_count && count > 1) + ambiguous = true; + } + } + return ambiguous ? std::array{} : 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.dim_x != input.dim_x || state.dim_y != input.dim_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.push_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.dim_x = input.dim_x; + state.dim_y = input.dim_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.dim_x) * size_t(input.dim_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; + std::array landed_shift{}; + 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. + std::array landed{}; + size_t landed_count = 0; + bool any_data = false; + std::array shift{}; + for (size_t count = 1; count <= state.pending.size(); ++count) { + shift[0] += state.pending[count - 1][0]; + shift[1] += state.pending[count - 1][1]; + // A prefix netting to zero is indistinguishable from "nothing landed yet". + // Accepting it would retire shifts the buffers have still to apply. + if (shift[0] == 0 && shift[1] == 0) + continue; + const double ratio = shift_match_ratio(input, shift[0], shift[1]); + // 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[0]; + const int32_t dwy = landed[1]; + 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.dim_x || + movement.target_y < 0 || + movement.target_y >= input.dim_y; + }), + state.movements.end()); + // Facing describes creatures still on screen, so translate it rather than drop it. + if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { + std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + shifted[x * input.dim_y + y] = state.facing[sx * input.dim_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.dim_x) * size_t(input.dim_y), 0); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + landed_shift[0]; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + landed_shift[1]; + if (sy < 0 || sy >= input.dim_y) + continue; + rebased_previous[layer][x * input.dim_y + y] = + input.previous[layer][sx * input.dim_y + sy]; + } + } + previous_layers[layer] = rebased_previous[layer].data(); + } + } + if (!suppress) { + const int32_t tile_count = input.dim_x * input.dim_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.dim_x) * size_t(input.dim_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.dim_x, input.dim_y) + : std::array{}; + for (int32_t x = 0; x < input.dim_x; ++x) { + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t target = x * input.dim_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[0] != 0 || shared_delta[1] != 0) { + const int32_t source_x = x + shared_delta[0]; + const int32_t source_y = y + shared_delta[1]; + if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && + source_y < input.dim_y) { + const int32_t candidate = source_x * input.dim_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.dim_x || source_y < 0 || + source_y >= input.dim_y) + continue; + const int32_t candidate = source_x * input.dim_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.dim_y); + float visual_source_y = float(source % input.dim_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, x, y, + frame_time_ms}); + if (static_cast(layer) == + viewport_visual_layer::center && + state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y) && + !facing_at_frame_start.empty() && + facing_at_frame_start.size() == state.facing.size()) { + const int32_t source_tile_x = source / input.dim_y; + const int32_t target_index = x * input.dim_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.dim_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.dim_x) * size_t(input.dim_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.dim_x || y < 0 || y >= state.dim_y) + break; + const size_t index = size_t(x) * size_t(state.dim_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_x, movement.source_y, + 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 From 72fb17b8308c4000d888bfe3ca56262aa56056cb Mon Sep 17 00:00:00 2001 From: notliad Date: Mon, 10 Aug 2026 15:20:11 -0300 Subject: [PATCH 2/6] refactor(smooth-movement): split implementation into focused files --- docs/plugins/smooth-movement.rst | 17 - plugins/smooth-movement/CMakeLists.txt | 5 +- plugins/smooth-movement/smooth-movement.cpp | 1260 +----------------- plugins/smooth-movement/visual_animation.cpp | 581 ++++++++ plugins/smooth-movement/visual_animation.h | 597 +-------- plugins/smooth-movement/visual_renderer.cpp | 890 +++++++++++++ plugins/smooth-movement/visual_renderer.h | 24 + 7 files changed, 1569 insertions(+), 1805 deletions(-) create mode 100644 plugins/smooth-movement/visual_animation.cpp create mode 100644 plugins/smooth-movement/visual_renderer.cpp create mode 100644 plugins/smooth-movement/visual_renderer.h diff --git a/docs/plugins/smooth-movement.rst b/docs/plugins/smooth-movement.rst index 2cdc579a3e..cdee603b89 100644 --- a/docs/plugins/smooth-movement.rst +++ b/docs/plugins/smooth-movement.rst @@ -18,7 +18,6 @@ Usage enable smooth-movement smooth-movement - smooth-movement camera on|off|reset| smooth-movement flip on|off smooth-movement zlevel on|off @@ -35,28 +34,12 @@ Examples ``smooth-movement flip on`` Horizontally flip creatures so they face their direction of travel. -``smooth-movement camera on`` - Enable smooth camera scrolling and pixel-precise middle-mouse dragging. - -``smooth-movement camera 0.25 -0.25`` - Enable the free camera and offset the view by a quarter tile east and - north of the tile grid. - -``smooth-movement camera reset`` - Return the free camera to the tile grid without disabling it. - ``smooth-movement zlevel on`` Also animate movement on visible z-levels below the main level. Settings -------- -``camera`` (default: off) - Enable or disable smooth camera scrolling and free-camera dragging. The - ``reset`` argument clears the persistent sub-tile offset. Two numeric - arguments set the horizontal and vertical offsets in tiles; each must be - between -0.99 and 0.99. - ``flip`` (default: off) Flip creature sprites horizontally according to their last horizontal movement. Items, vehicles, and designation icons keep their normal diff --git a/plugins/smooth-movement/CMakeLists.txt b/plugins/smooth-movement/CMakeLists.txt index d2ca2a349a..9f33ca541a 100644 --- a/plugins/smooth-movement/CMakeLists.txt +++ b/plugins/smooth-movement/CMakeLists.txt @@ -1,3 +1,4 @@ -dfhack_plugin(smooth-movement smooth-movement.cpp LINK_LIBRARIES lua) +dfhack_plugin(smooth-movement smooth-movement.cpp visual_animation.cpp visual_renderer.cpp + LINK_LIBRARIES lua) target_include_directories(smooth-movement PRIVATE ${SDL2_INCLUDE_DIRS}) -dfhack_test(smooth-movement-test test_visual_animation_manager.cpp) +dfhack_test(smooth-movement-test "test_visual_animation_manager.cpp;visual_animation.cpp") diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp index aadaf44ad7..6f17d5169b 100644 --- a/plugins/smooth-movement/smooth-movement.cpp +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -1,1127 +1,26 @@ // SPDX-License-Identifier: MIT -#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/enabler.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" +#include "visual_renderer.h" using namespace DFHack; DFHACK_PLUGIN("smooth-movement"); DFHACK_PLUGIN_IS_ENABLED(is_enabled); - -REQUIRE_GLOBAL(enabler); -REQUIRE_GLOBAL(gps); -REQUIRE_GLOBAL(window_x); -REQUIRE_GLOBAL(window_y); -REQUIRE_GLOBAL(window_z); +REQUIRE_GLOBAL_NO_USE(gps); +REQUIRE_GLOBAL_NO_USE(window_x); +REQUIRE_GLOBAL_NO_USE(window_y); +REQUIRE_GLOBAL_NO_USE(window_z); namespace { -// 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_RenderSetClipRect) render_set_clip_rect = nullptr; -decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; -decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; - -visual_animation_managerst animation_manager; -std::set> previous_coverage; -uint64_t visual_context_revision = 0; -const void *previous_viewport = nullptr; -std::array previous_view_signature{}; -bool has_view_signature = 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. -int32_t previous_pan_x = 0; -int32_t previous_pan_y = 0; -bool has_pan_context = false; -bool flip_enabled = false; -bool zlevel_enabled = false; - -// --- free camera ------------------------------------------------------------------------------- -// The camera is visually unbound from the tile grid. Two layered offsets: -// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by pixel-perfect -// middle-mouse drag panning (the view rests wherever released, mid-tile or not) -// and by the `smooth-movement camera ` console command. Survives zoom -// and z-level changes. Kept in [-0.5,0.5] by normalization: whole-tile parts are -// folded into window_x/window_y (a plain UI scroll write -- NEVER the viewport -// dims, which crash DF; the sub-tile strip this leaves at one screen edge has no -// buffer data and stays black). -// transient -- the decaying scroll glide from before, in pixels, layered on top. -// Render offset = transient + rest*tile. window_x/window_y remain the game's own tile camera. -bool camera_enabled = false; // OFF by default: plain `enable smooth-movement` - // keeps upstream behavior (creature interpolation - // only); `smooth-movement camera on` opts in. -constexpr int32_t camera_max_glide_tiles = 3; // per-jump: farther than this snaps instantly -constexpr double camera_tau_ms = 35.0; // transient catch-up (~95% done after 100ms) -double transient_x = 0.0; // decaying glide offset, pixels -double transient_y = 0.0; -double rest_x = 0.0; // persistent free-camera offset, tiles -double rest_y = 0.0; // (positive = view sits WEST/NORTH of window) -int32_t camera_pending_dx = 0; // scroll delta announced but not yet in the buffers -int32_t camera_pending_dy = 0; -int32_t camera_pending_frames = 0; -int32_t self_scroll_x = 0; // window deltas WE wrote: visual no-ops when landing -int32_t self_scroll_y = 0; -bool drag_active = false; -double drag_anchor_vx = 0.0; // visual camera at drag start, tiles -double drag_anchor_vy = 0.0; -int32_t drag_anchor_mx = 0; // precise mouse at drag start, pixels -int32_t drag_anchor_my = 0; -bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw -int32_t camera_prev_wx = 0; // window-scroll observation baseline -int32_t camera_prev_wy = 0; -bool camera_has_prev = false; - -double tile_px(const df::renderer_2d_base *renderer) { - const int32_t zoom = renderer->viewport_zoom_factor; - return double(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); -} - -// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, or -1 when there -// is nothing to compare (empty background). -double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int32_t dwy) { - int32_t considered = 0; - int32_t matches = 0; - for (int32_t x = 0; x < vp->dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= vp->dim_x) - continue; - for (int32_t y = 0; y < vp->dim_y; ++y) { - const int32_t sy = y + dwy; - if (sy < 0 || sy >= vp->dim_y) - continue; - const int32_t cur = vp->screentexpos_background[x * vp->dim_y + y]; - if (cur == 0) - continue; - ++considered; - if (vp->screentexpos_background_old[sx * vp->dim_y + sy] == cur) - ++matches; - } - } - if (considered == 0) - return -1.0; - return double(matches) / double(considered); -} - -// Cancel everything except the persistent rest offset (the camera keeps its sub-tile position -// across zoom/z/resize; only the in-flight animation state is unfollowable). -void clear_camera_pending() { - camera_pending_dx = 0; - camera_pending_dy = 0; - camera_pending_frames = 0; - self_scroll_x = 0; - self_scroll_y = 0; -} - -void cancel_camera_transients() { - transient_x = 0.0; - transient_y = 0.0; - clear_camera_pending(); - drag_active = false; -} - -void set_camera_enabled(bool enable) { - if (camera_enabled == enable) - return; - camera_enabled = enable; - cancel_camera_transients(); - rest_x = 0.0; - rest_y = 0.0; - camera_has_prev = false; // fresh observation baseline; no phantom scroll on re-enable - // camera_was_offset stays: the render path issues one cleanup redraw if we were mid-offset. -} - -// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal edge strip). The -// visual position is unchanged: the window write is attributed via self_scroll when it lands. -void normalize_rest() { - const int32_t kx = int32_t(-std::llround(rest_x)); - const int32_t ky = int32_t(-std::llround(rest_y)); - if (kx != 0 && window_x != nullptr && *window_x + kx >= 0) { - *window_x += kx; - self_scroll_x += kx; - } - if (ky != 0 && window_y != nullptr && *window_y + ky >= 0) { - *window_y += ky; - self_scroll_y += ky; - } -} - -// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization writes are visual -// no-ops (they move into rest); the remainder is a real scroll and glides -- unless a drag is -// driving the position directly, in which case it folds into rest wholesale. -void attribute_landed(int32_t ax, int32_t ay, double tile) { - int32_t sx = 0; - if (self_scroll_x != 0 && (self_scroll_x > 0) == (ax > 0) && ax != 0) - sx = (std::abs(self_scroll_x) <= std::abs(ax)) ? self_scroll_x : ax; - int32_t sy = 0; - if (self_scroll_y != 0 && (self_scroll_y > 0) == (ay > 0) && ay != 0) - sy = (std::abs(self_scroll_y) <= std::abs(ay)) ? self_scroll_y : ay; - self_scroll_x -= sx; - self_scroll_y -= sy; - rest_x += sx; - rest_y += sy; - const int32_t gx = ax - sx; - const int32_t gy = ay - sy; - if (drag_active) { - rest_x += gx; - rest_y += gy; - } else { - transient_x += gx * tile; - transient_y += gy * tile; - const double cap = tile * (camera_max_glide_tiles + 0.5); - transient_x = std::clamp(transient_x, -cap, cap); - transient_y = std::clamp(transient_y, -cap, cap); - } -} - -// Per-frame camera bookkeeping: observe window scrolls, attribute them when the buffers apply -// them (glide vs our own normalization writes), drive the drag, decay the transient. -void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst *vp, - uint32_t delta_ms) { - if (!camera_enabled) - return; - const double tile = tile_px(renderer); - const int32_t wx = window_x ? *window_x : 0; - const int32_t wy = window_y ? *window_y : 0; - if (camera_has_prev && (wx != camera_prev_wx || wy != camera_prev_wy)) { - const int32_t dx = wx - camera_prev_wx; - const int32_t dy = wy - camera_prev_wy; - if ((std::abs(dx) > camera_max_glide_tiles || std::abs(dy) > camera_max_glide_tiles) && - !drag_active) - cancel_camera_transients(); // teleport-like jump (recenter/minimap): snap - else { - camera_pending_dx += dx; - camera_pending_dy += dy; - camera_pending_frames = 0; - } - } - camera_prev_wx = wx; - camera_prev_wy = wy; - camera_has_prev = true; - - if (camera_pending_dx != 0 || camera_pending_dy != 0) { - if (std::abs(camera_pending_dx) > 6 || std::abs(camera_pending_dy) > 6) { - // Scrolling far outran detection: snap (keep rest, drop the animation debt). - transient_x = 0.0; - transient_y = 0.0; - clear_camera_pending(); - } else { - // Fast scrolling applies the pending delta PIECEMEAL: the buffers may hold +1 of a - // pending +3 this frame. Testing only the total made landings miss, time out, and - // snap -- the fast-scroll jitter. Instead, find the LARGEST applied prefix of the - // pending scroll and attribute just that; the rest keeps pending. Ties between - // qualifying shifts only happen on uniform terrain, where mistiming is invisible. - const int32_t stepx = (camera_pending_dx > 0) - (camera_pending_dx < 0); - const int32_t stepy = (camera_pending_dy > 0) - (camera_pending_dy < 0); - int32_t best_ax = 0, best_ay = 0, best_mag = -1; - double best_score = -1.0; - bool no_data = false; - for (int32_t ix = 0; ix <= std::abs(camera_pending_dx); ++ix) { - for (int32_t iy = 0; iy <= std::abs(camera_pending_dy); ++iy) { - const double score = background_match_ratio(vp, ix * stepx, iy * stepy); - if (score < 0.0) { - no_data = true; - break; - } - const int32_t mag = ix + iy; - if (score >= 0.6 && - (mag > best_mag || (mag == best_mag && score > best_score))) { - best_mag = mag; - best_score = score; - best_ax = ix * stepx; - best_ay = iy * stepy; - } - } - if (no_data) - break; - } - if (no_data) { - // Nothing to compare against (empty background): give up on attribution. - clear_camera_pending(); - } else if (best_mag > 0) { - attribute_landed(best_ax, best_ay, tile); - camera_pending_dx -= best_ax; - camera_pending_dy -= best_ay; - camera_pending_frames = 0; - } else if (best_mag == 0) { - // Content demonstrably hasn't moved yet: keep waiting, no timeout pressure. - camera_pending_frames = 0; - } else if (++camera_pending_frames > 4) { - // Neither static nor any prefix recognizable (heavy simultaneous change): - // drop the debt without touching the in-flight glide. - clear_camera_pending(); - } - } - } - - // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and rests where - // released. DF's own drag still moves window in tile steps; rest carries the remainder. - // Positions are tracked against the CONTENT window (window minus unlanded jumps) so the - // buffer lag never causes a visible stutter. - const bool mbut = enabler != nullptr && enabler->mouse_mbut; - const double content_wx = double(wx - camera_pending_dx); - const double content_wy = double(wy - camera_pending_dy); - if (mbut && !drag_active && gps != nullptr) { - drag_active = true; - drag_anchor_vx = content_wx - rest_x - transient_x / tile; - drag_anchor_vy = content_wy - rest_y - transient_y / tile; - drag_anchor_mx = gps->precise_mouse_x; - drag_anchor_my = gps->precise_mouse_y; - transient_x = 0.0; - transient_y = 0.0; - } - if (drag_active) { - if (!mbut) { - drag_active = false; - normalize_rest(); - } else if (gps != nullptr) { - double vx = drag_anchor_vx - double(gps->precise_mouse_x - drag_anchor_mx) / tile; - double vy = drag_anchor_vy - double(gps->precise_mouse_y - drag_anchor_my) / tile; - rest_x = content_wx - vx; - rest_y = content_wy - vy; - // If DF's own drag disagrees by more than a tile and a half, rebase on its view. - const double lim = 1.5; - if (rest_x < -lim || rest_x > lim || rest_y < -lim || rest_y > lim) { - rest_x = std::clamp(rest_x, -lim, lim); - rest_y = std::clamp(rest_y, -lim, lim); - drag_anchor_vx = - content_wx - rest_x + double(gps->precise_mouse_x - drag_anchor_mx) / tile; - drag_anchor_vy = - content_wy - rest_y + double(gps->precise_mouse_y - drag_anchor_my) / tile; - } - } - } - - if (transient_x != 0.0 || transient_y != 0.0) { - const double k = std::exp(-double(delta_ms) / camera_tau_ms); - transient_x *= k; - transient_y *= k; - if (std::abs(transient_x) < 0.5 && std::abs(transient_y) < 0.5) { - transient_x = 0.0; - transient_y = 0.0; - } - } -} - -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 std::array signature = {window_z ? *window_z : 0, - vp->dim_x, - vp->dim_y, - vp->clipx[0], - vp->clipx[1], - vp->clipy[0], - vp->clipy[1], - renderer->viewport_zoom_factor, - renderer->origin_x, - renderer->origin_y, - gps->dimx, - gps->dimy}; - const bool changed = - !has_view_signature || previous_viewport != vp || previous_view_signature != signature; - if (changed) { - ++visual_context_revision; - previous_coverage.clear(); - cancel_camera_transients(); - } - previous_viewport = vp; - previous_view_signature = signature; - has_view_signature = 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, - vp->dim_x, - vp->dim_y, - visual_context_revision, - visual_layers(const_viewport), - visual_layers(const_viewport, true), - 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; - float source_x; - float source_y; - int32_t target_x; - int32_t target_y; - int32_t texpos; - float progress; - SDL_Texture *texture; - bool mirrored = false; - int32_t mirror_shift = 0; - std::set> coverage; -}; - -using tile_coveragest = std::set>; - -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({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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = visual_layer_at_draw_order(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.center_x && - anchor.target_y == y + descriptor.center_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.center_x, - y + mirror_descriptor.center_y) != native_sprite_facing; - // The anchor's own layer has center_x 0, so it flips in place. - const int32_t mirror_shift = - mirrored ? mirrored_tile_x(x, x + mirror_descriptor.center_x) - x : 0; - render_proxyst proxy = {static_cast(layer), - movement.source_x, - movement.source_y, - 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) { - std::set> mirrored_coverage; - for (const auto &tile : proxy.coverage) - mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); - for (const auto &tile : mirrored_coverage) { - if (!inside_clip(vp, tile.first, tile.second)) { - blocked = true; - break; - } - if (visual_render_group(proxy.layer) == visual_render_groupst::main && - has_fire(vp, tile.first, tile.second)) { - 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = - visual_layer_at_draw_order(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.center_x; - const int32_t y = anchor_y - descriptor.center_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), - 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; - update_camera(renderer, vp, animation_manager.get_frame_delta_ms()); - const double cam_tile = tile_px(renderer); - const int32_t glide_x = int32_t(std::lround(transient_x + rest_x * cam_tile)); - const int32_t glide_y = int32_t(std::lround(transient_y + rest_y * cam_tile)); - const bool glide = glide_x != 0 || glide_y != 0; - if (!glide && camera_was_offset) { - // The camera just re-joined the grid: one engine redraw replaces the last shifted frame. - camera_was_offset = false; - if (gps != nullptr) - ++gps->force_full_display_count; - } - if (glide) - camera_was_offset = true; - const bool animation_redraw = - zlevel_enabled ? animation_manager.requires_full_redraw() - : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); - if (!glide && !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); - - if (glide) { - // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the world (and - // the creature proxies, which read origin at draw time) renders between tiles. The engine - // already drew this frame at the snapped position; everything here overdraws it, clipped - // to the map rect so shifted tiles never spill over the UI. The uncovered strip on the - // trailing edge stays black until the glide lands. - const SDL_Rect map_rect = {tile_pixel(vp->clipx[0], renderer->origin_x, zoom), - tile_pixel(vp->clipy[0], renderer->origin_y, zoom), - tile_pixel(vp->clipx[1] + 1, renderer->origin_x, zoom) - - tile_pixel(vp->clipx[0], renderer->origin_x, zoom), - tile_pixel(vp->clipy[1] + 1, renderer->origin_y, zoom) - - tile_pixel(vp->clipy[0], renderer->origin_y, zoom)}; - render_set_clip_rect(sdl_renderer, &map_rect); - 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); - render_fill_rect(sdl_renderer, &map_rect); - set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); - - const int32_t saved_origin_x = renderer->origin_x; - const int32_t saved_origin_y = renderer->origin_y; - renderer->origin_x += glide_x; - renderer->origin_y += glide_y; - for (int32_t x = vp->clipx[0]; x <= vp->clipx[1]; ++x) { - for (int32_t y = vp->clipy[0]; y <= vp->clipy[1]; ++y) - redraw_world_tile(renderer, viewport_renders, coverage, x, y); - } - draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); - renderer->origin_x = saved_origin_x; - renderer->origin_y = saved_origin_y; - render_set_clip_rect(sdl_renderer, nullptr); - - // Everything was repainted; per-tile coverage bookkeeping restarts after the glide. - previous_coverage.clear(); - return; - } - - 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, ()); @@ -1130,148 +29,47 @@ struct renderer_hook : df::renderer_2d_base { 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); + smooth_movement::render(this); INTERPOSE_NEXT(update_all)(); } -void clear_sdl_bindings() { - render_copy_f = nullptr; - render_copy_ex_f = nullptr; - render_fill_rect = nullptr; - render_set_clip_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_RenderSetClipRect, render_set_clip_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_view_signature = {}; - has_view_signature = false; - previous_pan_x = 0; - previous_pan_y = 0; - has_pan_context = false; - cancel_camera_transients(); - rest_x = 0.0; - rest_y = 0.0; - camera_enabled = false; - camera_has_prev = false; - camera_was_offset = 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("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the grid)\n", - camera_enabled ? "on" : "off", -rest_x, -rest_y); - out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); - out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + out.print("sprite flipping: {}\n", smooth_movement::flip_enabled() ? "on" : "off"); + out.print("lower z-level animation: {}\n", + smooth_movement::zlevel_enabled() ? "on" : "off"); return CR_OK; } - if (parameters[0] == "camera") { - if (parameters.size() == 1) { - out.print("free camera: {}, offset {:.3f} {:.3f}\n", camera_enabled ? "on" : "off", - -rest_x, -rest_y); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "on") { - set_camera_enabled(true); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "off") { - set_camera_enabled(false); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "reset") { - rest_x = 0.0; - rest_y = 0.0; - return CR_OK; - } - if (parameters.size() == 3) { - try { - const double fx = std::stod(parameters[1]); - const double fy = std::stod(parameters[2]); - if (fx < -0.99 || fx > 0.99 || fy < -0.99 || fy > 0.99) { - out.printerr("offsets must be within -0.99..0.99 tiles\n"); - return CR_FAILURE; - } - // User-facing: positive = view sits east/south of the grid position. - set_camera_enabled(true); - rest_x = -fx; - rest_y = -fy; - normalize_rest(); - return CR_OK; - } catch (...) { - return CR_WRONG_USAGE; - } - } - return CR_WRONG_USAGE; - } 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"); + out.print("sprite flipping: {}\n", smooth_movement::flip_enabled() ? "on" : "off"); return CR_OK; } - return CR_WRONG_USAGE; + if (parameters.size() == 2 && parameters[1] == "on") + smooth_movement::set_flip_enabled(true); + else if (parameters.size() == 2 && parameters[1] == "off") + smooth_movement::set_flip_enabled(false); + else + return CR_WRONG_USAGE; + out.print("smooth-movement: sprite flipping {}\n", + smooth_movement::flip_enabled() ? "enabled" : "disabled"); + return CR_OK; } if (parameters[0] == "zlevel") { if (parameters.size() == 1) { - out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); + out.print("lower z-level animation: {}\n", + smooth_movement::zlevel_enabled() ? "on" : "off"); return CR_OK; } if (parameters.size() == 2 && parameters[1] == "on") - zlevel_enabled = true; + smooth_movement::set_zlevel_enabled(true); else if (parameters.size() == 2 && parameters[1] == "off") - zlevel_enabled = false; + smooth_movement::set_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"); + smooth_movement::zlevel_enabled() ? "enabled" : "disabled"); return CR_OK; } return CR_WRONG_USAGE; @@ -1289,20 +87,16 @@ 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)) + if (!smooth_movement::initialize(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(); + smooth_movement::shutdown(); return CR_FAILURE; } } else { INTERPOSE_HOOK(renderer_hook, update_all).remove(); - reset_state(); - clear_sdl_bindings(); - if (gps != nullptr) - ++gps->force_full_display_count; + smooth_movement::shutdown(); } is_enabled = enable; out.print("smooth-movement: {}\n", enable ? "enabled" : "disabled"); diff --git a/plugins/smooth-movement/visual_animation.cpp b/plugins/smooth-movement/visual_animation.cpp new file mode 100644 index 0000000000..2fb8c53e83 --- /dev/null +++ b/plugins/smooth-movement/visual_animation.cpp @@ -0,0 +1,581 @@ +// SPDX-License-Identifier: MIT + +#include "visual_animation.h" + +void visual_animation_managerst::clear_pending(viewport_animationst &state) { + state.pending.clear(); + state.pending_frames = 0; + state.pending_age = 0; +} + +void visual_animation_managerst::abandon_pending(viewport_animationst &state) { + state.movements.clear(); + clear_pending(state); +} + +void visual_animation_managerst::reset_facing(viewport_animationst &state) { + std::fill(state.facing.begin(), state.facing.end(), int8_t(native_sprite_facing)); + state.has_mirrored = false; +} + +void visual_animation_managerst::reset_tracking(viewport_animationst &state) { + abandon_pending(state); + state.suppress_frames = 0; +} + +uint64_t visual_animation_managerst::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.dim_x * input.dim_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; +} + +double visual_animation_managerst::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.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t texpos = current[x * input.dim_y + y]; + if (texpos == 0) + continue; + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + ++considered; + if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) + ++matches; + } + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); +} + +std::array visual_animation_managerst::shared_movement_delta(const int32_t *current, + const int32_t *previous, + int32_t dim_x, + int32_t dim_y) { + std::array best{}; + 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 = {dx, dy}; + ambiguous = false; + } else if (count == best_count && count > 1) + ambiguous = true; + } + } + return ambiguous ? std::array{} : best; +} + +visual_animation_managerst::viewport_animationst & +visual_animation_managerst::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 visual_animation_managerst::movement_progress(uint32_t start_time_ms) const { + return animation_progress(frame_time_ms, start_time_ms, movement_duration_ms); +} + +void visual_animation_managerst::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 visual_animation_managerst::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.dim_x != input.dim_x || state.dim_y != input.dim_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.push_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.dim_x = input.dim_x; + state.dim_y = input.dim_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.dim_x) * size_t(input.dim_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; + std::array landed_shift{}; + 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. + std::array landed{}; + size_t landed_count = 0; + bool any_data = false; + std::array shift{}; + for (size_t count = 1; count <= state.pending.size(); ++count) { + shift[0] += state.pending[count - 1][0]; + shift[1] += state.pending[count - 1][1]; + // A prefix netting to zero is indistinguishable from "nothing landed + // yet". Accepting it would retire shifts the buffers have still to apply. + if (shift[0] == 0 && shift[1] == 0) + continue; + const double ratio = shift_match_ratio(input, shift[0], shift[1]); + // 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[0]; + const int32_t dwy = landed[1]; + 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.dim_x || + movement.target_y < 0 || + movement.target_y >= input.dim_y; + }), + state.movements.end()); + // Facing describes creatures still on screen, so translate it rather than + // drop it. + if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { + std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + shifted[x * input.dim_y + y] = state.facing[sx * input.dim_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.dim_x) * size_t(input.dim_y), 0); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + landed_shift[0]; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + landed_shift[1]; + if (sy < 0 || sy >= input.dim_y) + continue; + rebased_previous[layer][x * input.dim_y + y] = + input.previous[layer][sx * input.dim_y + sy]; + } + } + previous_layers[layer] = rebased_previous[layer].data(); + } + } + if (!suppress) { + const int32_t tile_count = input.dim_x * input.dim_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.dim_x) * size_t(input.dim_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.dim_x, input.dim_y) + : std::array{}; + for (int32_t x = 0; x < input.dim_x; ++x) { + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t target = x * input.dim_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[0] != 0 || shared_delta[1] != 0) { + const int32_t source_x = x + shared_delta[0]; + const int32_t source_y = y + shared_delta[1]; + if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && + source_y < input.dim_y) { + const int32_t candidate = source_x * input.dim_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.dim_x || source_y < 0 || + source_y >= input.dim_y) + continue; + const int32_t candidate = source_x * input.dim_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.dim_y); + float visual_source_y = float(source % input.dim_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, x, y, + frame_time_ms}); + if (static_cast(layer) == + viewport_visual_layer::center && + state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y) && + !facing_at_frame_start.empty() && + facing_at_frame_start.size() == state.facing.size()) { + const int32_t source_tile_x = source / input.dim_y; + const int32_t target_index = x * input.dim_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.dim_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.dim_x) * size_t(input.dim_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 visual_animation_managerst::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 visual_animation_managerst::get_frame_time_ms() const { return frame_time_ms; } + +uint32_t visual_animation_managerst::get_frame_delta_ms() const { return frame_delta_ms; } + +visual_facingst visual_animation_managerst::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.dim_x || y < 0 || y >= state.dim_y) + break; + const size_t index = size_t(x) * size_t(state.dim_y) + size_t(y); + if (index >= state.facing.size()) + break; + return static_cast(state.facing[index]); + } + return native_sprite_facing; +} + +bool visual_animation_managerst::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 visual_animation_managerst::requires_full_redraw() const { return force_full_redraw; } + +bool visual_animation_managerst::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 visual_animation_managerst::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_x, movement.source_y, + 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 {}; +} diff --git a/plugins/smooth-movement/visual_animation.h b/plugins/smooth-movement/visual_animation.h index 5c0153eb5b..b163fe1e1f 100644 --- a/plugins/smooth-movement/visual_animation.h +++ b/plugins/smooth-movement/visual_animation.h @@ -108,8 +108,9 @@ struct viewport_visual_animation_inputst { 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. + // 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. int32_t pan_x = 0; int32_t pan_y = 0; @@ -150,7 +151,8 @@ inline int32_t inherited_visual_source_tile(int32_t overlay_target, float center 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. +// 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. @@ -162,7 +164,8 @@ constexpr visual_facingst facing_after_move(int32_t dx, visual_facingst previous return previous; } -// center_x is only -1, 0 or +1, so a creature is at most three columns wide here. +// center_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); } @@ -186,26 +189,31 @@ class visual_animation_managerst { 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. + // 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. + // Stationary mirrored creatures are repainted every frame; this is the + // cheap pre-check. bool has_mirrored = false; int32_t pan_x = 0; int32_t pan_y = 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. + // 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. + // 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. + // Set while the previous buffer still belongs to a view that has been left + // behind. bool previous_view_stale = false; }; @@ -216,578 +224,61 @@ class visual_animation_managerst { 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. + // 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. + // 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 clear_pending(viewport_animationst &state); - static void abandon_pending(viewport_animationst &state) { - state.movements.clear(); - clear_pending(state); - } + static void abandon_pending(viewport_animationst &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_facing(viewport_animationst &state); - static void reset_tracking(viewport_animationst &state) { - abandon_pending(state); - state.suppress_frames = 0; - } + static void reset_tracking(viewport_animationst &state); - // 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.dim_x * input.dim_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; - } + // 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); - // Fraction of tracked sprites consistent with a buffer shift: current[x]==previous[x+dwx]. - // Negative when there is nothing to compare. + // 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.dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t texpos = current[x * input.dim_y + y]; - if (texpos == 0) - continue; - const int32_t sy = y + dwy; - if (sy < 0 || sy >= input.dim_y) - continue; - ++considered; - if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) - ++matches; - } - } - } - if (considered == 0) - return -1.0; - return double(matches) / double(considered); - } + int32_t dwy); static std::array shared_movement_delta(const int32_t *current, const int32_t *previous, int32_t dim_x, - int32_t dim_y) { - std::array best{}; - 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 = {dx, dy}; - ambiguous = false; - } else if (count == best_count && count > 1) - ambiguous = true; - } - } - return ambiguous ? std::array{} : best; - } + int32_t dim_y); - 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(); - } + viewport_animationst &get_viewport(const viewport_visual_animation_inputst &input); - float movement_progress(uint32_t start_time_ms) const { - return animation_progress(frame_time_ms, start_time_ms, movement_duration_ms); - } + float movement_progress(uint32_t start_time_ms) const; 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 begin_frame(uint32_t now_ms); - 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; - } + void synchronize_viewport(const viewport_visual_animation_inputst &input); - // 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.dim_x != input.dim_x || state.dim_y != input.dim_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.push_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.dim_x = input.dim_x; - state.dim_y = input.dim_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.dim_x) * size_t(input.dim_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; - } + void end_frame(); - // 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; - std::array landed_shift{}; - 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. - std::array landed{}; - size_t landed_count = 0; - bool any_data = false; - std::array shift{}; - for (size_t count = 1; count <= state.pending.size(); ++count) { - shift[0] += state.pending[count - 1][0]; - shift[1] += state.pending[count - 1][1]; - // A prefix netting to zero is indistinguishable from "nothing landed yet". - // Accepting it would retire shifts the buffers have still to apply. - if (shift[0] == 0 && shift[1] == 0) - continue; - const double ratio = shift_match_ratio(input, shift[0], shift[1]); - // 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[0]; - const int32_t dwy = landed[1]; - 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.dim_x || - movement.target_y < 0 || - movement.target_y >= input.dim_y; - }), - state.movements.end()); - // Facing describes creatures still on screen, so translate it rather than drop it. - if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { - std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); - for (int32_t x = 0; x < input.dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t sy = y + dwy; - if (sy < 0 || sy >= input.dim_y) - continue; - shifted[x * input.dim_y + y] = state.facing[sx * input.dim_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.dim_x) * size_t(input.dim_y), 0); - for (int32_t x = 0; x < input.dim_x; ++x) { - const int32_t sx = x + landed_shift[0]; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t sy = y + landed_shift[1]; - if (sy < 0 || sy >= input.dim_y) - continue; - rebased_previous[layer][x * input.dim_y + y] = - input.previous[layer][sx * input.dim_y + sy]; - } - } - previous_layers[layer] = rebased_previous[layer].data(); - } - } - if (!suppress) { - const int32_t tile_count = input.dim_x * input.dim_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.dim_x) * size_t(input.dim_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.dim_x, input.dim_y) - : std::array{}; - for (int32_t x = 0; x < input.dim_x; ++x) { - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t target = x * input.dim_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[0] != 0 || shared_delta[1] != 0) { - const int32_t source_x = x + shared_delta[0]; - const int32_t source_y = y + shared_delta[1]; - if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && - source_y < input.dim_y) { - const int32_t candidate = source_x * input.dim_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.dim_x || source_y < 0 || - source_y >= input.dim_y) - continue; - const int32_t candidate = source_x * input.dim_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.dim_y); - float visual_source_y = float(source % input.dim_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, x, y, - frame_time_ms}); - if (static_cast(layer) == - viewport_visual_layer::center && - state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y) && - !facing_at_frame_start.empty() && - facing_at_frame_start.size() == state.facing.size()) { - const int32_t source_tile_x = source / input.dim_y; - const int32_t target_index = x * input.dim_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.dim_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.dim_x) * size_t(input.dim_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; - uint32_t get_frame_time_ms() const { return frame_time_ms; } + uint32_t get_frame_delta_ms() const; - 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; - 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.dim_x || y < 0 || y >= state.dim_y) - break; - const size_t index = size_t(x) * size_t(state.dim_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; - 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; - 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; - } + bool has_active_movement(const void *viewport) const; 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_x, movement.source_y, - 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 {}; - } + int32_t target_x, int32_t target_y) const; }; #endif // SMOOTH_MOVEMENT_VISUAL_ANIMATION_H diff --git a/plugins/smooth-movement/visual_renderer.cpp b/plugins/smooth-movement/visual_renderer.cpp new file mode 100644 index 0000000000..1b62451436 --- /dev/null +++ b/plugins/smooth-movement/visual_renderer.cpp @@ -0,0 +1,890 @@ +// SPDX-License-Identifier: MIT + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "Core.h" +#include "MemAccess.h" +#include "PluginManager.h" + +#include "modules/DFSDL.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" +#include "visual_renderer.h" + +using namespace DFHack; + +using df::global::gps; +using df::global::window_x; +using df::global::window_y; +using df::global::window_z; + +namespace { + +// 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; +std::set> previous_coverage; +uint64_t visual_context_revision = 0; +const void *previous_viewport = nullptr; +std::array previous_view_signature{}; +bool has_view_signature = 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. +int32_t previous_pan_x = 0; +int32_t previous_pan_y = 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 std::array signature = {window_z ? *window_z : 0, + vp->dim_x, + vp->dim_y, + vp->clipx[0], + vp->clipx[1], + vp->clipy[0], + vp->clipy[1], + renderer->viewport_zoom_factor, + renderer->origin_x, + renderer->origin_y, + gps->dimx, + gps->dimy}; + const bool changed = + !has_view_signature || previous_viewport != vp || previous_view_signature != signature; + if (changed) { + ++visual_context_revision; + previous_coverage.clear(); + } + previous_viewport = vp; + previous_view_signature = signature; + has_view_signature = 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, + vp->dim_x, + vp->dim_y, + visual_context_revision, + visual_layers(const_viewport), + visual_layers(const_viewport, true), + 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; + float source_x; + float source_y; + int32_t target_x; + int32_t target_y; + int32_t texpos; + float progress; + SDL_Texture *texture; + bool mirrored = false; + int32_t mirror_shift = 0; + std::set> coverage; +}; + +using tile_coveragest = std::set>; + +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({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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = visual_layer_at_draw_order(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.center_x && + anchor.target_y == y + descriptor.center_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.center_x, + y + mirror_descriptor.center_y) != native_sprite_facing; + // The anchor's own layer has center_x 0, so it flips in place. + const int32_t mirror_shift = + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.center_x) - x : 0; + render_proxyst proxy = {static_cast(layer), + movement.source_x, + movement.source_y, + 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) { + std::set> mirrored_coverage; + for (const auto &tile : proxy.coverage) + mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); + for (const auto &tile : mirrored_coverage) { + if (!inside_clip(vp, tile.first, tile.second)) { + blocked = true; + break; + } + if (visual_render_group(proxy.layer) == visual_render_groupst::main && + has_fire(vp, tile.first, tile.second)) { + 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = + visual_layer_at_draw_order(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.center_x; + const int32_t y = anchor_y - descriptor.center_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), + 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); +} + +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_view_signature = {}; + has_view_signature = false; + previous_pan_x = 0; + previous_pan_y = 0; + has_pan_context = false; + flip_enabled = false; + zlevel_enabled = false; +} + +} // namespace + +namespace smooth_movement { + +bool initialize(color_ostream &out) { + reset_state(); + return load_sdl(out); +} + +void shutdown() { + reset_state(); + clear_sdl_bindings(); + if (gps != nullptr) + ++gps->force_full_display_count; +} + +void render(df::renderer_2d_base *renderer) { render_interpolated_world(renderer); } + +bool flip_enabled() { return ::flip_enabled; } + +void set_flip_enabled(bool enable) { + ::flip_enabled = enable; + if (gps != nullptr) + ++gps->force_full_display_count; +} + +bool zlevel_enabled() { return ::zlevel_enabled; } + +void set_zlevel_enabled(bool enable) { + ::zlevel_enabled = enable; + if (gps != nullptr) + ++gps->force_full_display_count; +} + +} // namespace smooth_movement diff --git a/plugins/smooth-movement/visual_renderer.h b/plugins/smooth-movement/visual_renderer.h new file mode 100644 index 0000000000..147fb6f3fe --- /dev/null +++ b/plugins/smooth-movement/visual_renderer.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: MIT + +#pragma once + +namespace DFHack { +class color_ostream; +} + +namespace df { +struct renderer_2d_base; +} + +namespace smooth_movement { + +bool initialize(DFHack::color_ostream &out); +void shutdown(); +void render(df::renderer_2d_base *renderer); + +bool flip_enabled(); +void set_flip_enabled(bool enable); +bool zlevel_enabled(); +void set_zlevel_enabled(bool enable); + +} // namespace smooth_movement From ba2d096f63234c6ceb7cdc87f751d5125c397fa4 Mon Sep 17 00:00:00 2001 From: notliad Date: Thu, 13 Aug 2026 16:26:20 -0300 Subject: [PATCH 3/6] Revert "refactor(smooth-movement): split implementation into focused files" This reverts commit 72fb17b8308c4000d888bfe3ca56262aa56056cb. --- docs/plugins/smooth-movement.rst | 17 + plugins/smooth-movement/CMakeLists.txt | 5 +- plugins/smooth-movement/smooth-movement.cpp | 1260 +++++++++++++++++- plugins/smooth-movement/visual_animation.cpp | 581 -------- plugins/smooth-movement/visual_animation.h | 597 ++++++++- plugins/smooth-movement/visual_renderer.cpp | 890 ------------- plugins/smooth-movement/visual_renderer.h | 24 - 7 files changed, 1805 insertions(+), 1569 deletions(-) delete mode 100644 plugins/smooth-movement/visual_animation.cpp delete mode 100644 plugins/smooth-movement/visual_renderer.cpp delete mode 100644 plugins/smooth-movement/visual_renderer.h diff --git a/docs/plugins/smooth-movement.rst b/docs/plugins/smooth-movement.rst index cdee603b89..2cdc579a3e 100644 --- a/docs/plugins/smooth-movement.rst +++ b/docs/plugins/smooth-movement.rst @@ -18,6 +18,7 @@ Usage enable smooth-movement smooth-movement + smooth-movement camera on|off|reset| smooth-movement flip on|off smooth-movement zlevel on|off @@ -34,12 +35,28 @@ Examples ``smooth-movement flip on`` Horizontally flip creatures so they face their direction of travel. +``smooth-movement camera on`` + Enable smooth camera scrolling and pixel-precise middle-mouse dragging. + +``smooth-movement camera 0.25 -0.25`` + Enable the free camera and offset the view by a quarter tile east and + north of the tile grid. + +``smooth-movement camera reset`` + Return the free camera to the tile grid without disabling it. + ``smooth-movement zlevel on`` Also animate movement on visible z-levels below the main level. Settings -------- +``camera`` (default: off) + Enable or disable smooth camera scrolling and free-camera dragging. The + ``reset`` argument clears the persistent sub-tile offset. Two numeric + arguments set the horizontal and vertical offsets in tiles; each must be + between -0.99 and 0.99. + ``flip`` (default: off) Flip creature sprites horizontally according to their last horizontal movement. Items, vehicles, and designation icons keep their normal diff --git a/plugins/smooth-movement/CMakeLists.txt b/plugins/smooth-movement/CMakeLists.txt index 9f33ca541a..d2ca2a349a 100644 --- a/plugins/smooth-movement/CMakeLists.txt +++ b/plugins/smooth-movement/CMakeLists.txt @@ -1,4 +1,3 @@ -dfhack_plugin(smooth-movement smooth-movement.cpp visual_animation.cpp visual_renderer.cpp - LINK_LIBRARIES lua) +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;visual_animation.cpp") +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 index 6f17d5169b..aadaf44ad7 100644 --- a/plugins/smooth-movement/smooth-movement.cpp +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -1,26 +1,1127 @@ // SPDX-License-Identifier: MIT +#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/enabler.h" +#include "df/graphic.h" +#include "df/graphic_viewportst.h" #include "df/renderer_2d_base.h" +#include "df/texture_fullid.h" -#include "visual_renderer.h" +#include "visual_animation.h" using namespace DFHack; DFHACK_PLUGIN("smooth-movement"); DFHACK_PLUGIN_IS_ENABLED(is_enabled); -REQUIRE_GLOBAL_NO_USE(gps); -REQUIRE_GLOBAL_NO_USE(window_x); -REQUIRE_GLOBAL_NO_USE(window_y); -REQUIRE_GLOBAL_NO_USE(window_z); + +REQUIRE_GLOBAL(enabler); +REQUIRE_GLOBAL(gps); +REQUIRE_GLOBAL(window_x); +REQUIRE_GLOBAL(window_y); +REQUIRE_GLOBAL(window_z); namespace { +// 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_RenderSetClipRect) render_set_clip_rect = nullptr; +decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; +decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; + +visual_animation_managerst animation_manager; +std::set> previous_coverage; +uint64_t visual_context_revision = 0; +const void *previous_viewport = nullptr; +std::array previous_view_signature{}; +bool has_view_signature = 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. +int32_t previous_pan_x = 0; +int32_t previous_pan_y = 0; +bool has_pan_context = false; +bool flip_enabled = false; +bool zlevel_enabled = false; + +// --- free camera ------------------------------------------------------------------------------- +// The camera is visually unbound from the tile grid. Two layered offsets: +// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by pixel-perfect +// middle-mouse drag panning (the view rests wherever released, mid-tile or not) +// and by the `smooth-movement camera ` console command. Survives zoom +// and z-level changes. Kept in [-0.5,0.5] by normalization: whole-tile parts are +// folded into window_x/window_y (a plain UI scroll write -- NEVER the viewport +// dims, which crash DF; the sub-tile strip this leaves at one screen edge has no +// buffer data and stays black). +// transient -- the decaying scroll glide from before, in pixels, layered on top. +// Render offset = transient + rest*tile. window_x/window_y remain the game's own tile camera. +bool camera_enabled = false; // OFF by default: plain `enable smooth-movement` + // keeps upstream behavior (creature interpolation + // only); `smooth-movement camera on` opts in. +constexpr int32_t camera_max_glide_tiles = 3; // per-jump: farther than this snaps instantly +constexpr double camera_tau_ms = 35.0; // transient catch-up (~95% done after 100ms) +double transient_x = 0.0; // decaying glide offset, pixels +double transient_y = 0.0; +double rest_x = 0.0; // persistent free-camera offset, tiles +double rest_y = 0.0; // (positive = view sits WEST/NORTH of window) +int32_t camera_pending_dx = 0; // scroll delta announced but not yet in the buffers +int32_t camera_pending_dy = 0; +int32_t camera_pending_frames = 0; +int32_t self_scroll_x = 0; // window deltas WE wrote: visual no-ops when landing +int32_t self_scroll_y = 0; +bool drag_active = false; +double drag_anchor_vx = 0.0; // visual camera at drag start, tiles +double drag_anchor_vy = 0.0; +int32_t drag_anchor_mx = 0; // precise mouse at drag start, pixels +int32_t drag_anchor_my = 0; +bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw +int32_t camera_prev_wx = 0; // window-scroll observation baseline +int32_t camera_prev_wy = 0; +bool camera_has_prev = false; + +double tile_px(const df::renderer_2d_base *renderer) { + const int32_t zoom = renderer->viewport_zoom_factor; + return double(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); +} + +// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, or -1 when there +// is nothing to compare (empty background). +double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int32_t dwy) { + int32_t considered = 0; + int32_t matches = 0; + for (int32_t x = 0; x < vp->dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= vp->dim_x) + continue; + for (int32_t y = 0; y < vp->dim_y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= vp->dim_y) + continue; + const int32_t cur = vp->screentexpos_background[x * vp->dim_y + y]; + if (cur == 0) + continue; + ++considered; + if (vp->screentexpos_background_old[sx * vp->dim_y + sy] == cur) + ++matches; + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); +} + +// Cancel everything except the persistent rest offset (the camera keeps its sub-tile position +// across zoom/z/resize; only the in-flight animation state is unfollowable). +void clear_camera_pending() { + camera_pending_dx = 0; + camera_pending_dy = 0; + camera_pending_frames = 0; + self_scroll_x = 0; + self_scroll_y = 0; +} + +void cancel_camera_transients() { + transient_x = 0.0; + transient_y = 0.0; + clear_camera_pending(); + drag_active = false; +} + +void set_camera_enabled(bool enable) { + if (camera_enabled == enable) + return; + camera_enabled = enable; + cancel_camera_transients(); + rest_x = 0.0; + rest_y = 0.0; + camera_has_prev = false; // fresh observation baseline; no phantom scroll on re-enable + // camera_was_offset stays: the render path issues one cleanup redraw if we were mid-offset. +} + +// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal edge strip). The +// visual position is unchanged: the window write is attributed via self_scroll when it lands. +void normalize_rest() { + const int32_t kx = int32_t(-std::llround(rest_x)); + const int32_t ky = int32_t(-std::llround(rest_y)); + if (kx != 0 && window_x != nullptr && *window_x + kx >= 0) { + *window_x += kx; + self_scroll_x += kx; + } + if (ky != 0 && window_y != nullptr && *window_y + ky >= 0) { + *window_y += ky; + self_scroll_y += ky; + } +} + +// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization writes are visual +// no-ops (they move into rest); the remainder is a real scroll and glides -- unless a drag is +// driving the position directly, in which case it folds into rest wholesale. +void attribute_landed(int32_t ax, int32_t ay, double tile) { + int32_t sx = 0; + if (self_scroll_x != 0 && (self_scroll_x > 0) == (ax > 0) && ax != 0) + sx = (std::abs(self_scroll_x) <= std::abs(ax)) ? self_scroll_x : ax; + int32_t sy = 0; + if (self_scroll_y != 0 && (self_scroll_y > 0) == (ay > 0) && ay != 0) + sy = (std::abs(self_scroll_y) <= std::abs(ay)) ? self_scroll_y : ay; + self_scroll_x -= sx; + self_scroll_y -= sy; + rest_x += sx; + rest_y += sy; + const int32_t gx = ax - sx; + const int32_t gy = ay - sy; + if (drag_active) { + rest_x += gx; + rest_y += gy; + } else { + transient_x += gx * tile; + transient_y += gy * tile; + const double cap = tile * (camera_max_glide_tiles + 0.5); + transient_x = std::clamp(transient_x, -cap, cap); + transient_y = std::clamp(transient_y, -cap, cap); + } +} + +// Per-frame camera bookkeeping: observe window scrolls, attribute them when the buffers apply +// them (glide vs our own normalization writes), drive the drag, decay the transient. +void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst *vp, + uint32_t delta_ms) { + if (!camera_enabled) + return; + const double tile = tile_px(renderer); + const int32_t wx = window_x ? *window_x : 0; + const int32_t wy = window_y ? *window_y : 0; + if (camera_has_prev && (wx != camera_prev_wx || wy != camera_prev_wy)) { + const int32_t dx = wx - camera_prev_wx; + const int32_t dy = wy - camera_prev_wy; + if ((std::abs(dx) > camera_max_glide_tiles || std::abs(dy) > camera_max_glide_tiles) && + !drag_active) + cancel_camera_transients(); // teleport-like jump (recenter/minimap): snap + else { + camera_pending_dx += dx; + camera_pending_dy += dy; + camera_pending_frames = 0; + } + } + camera_prev_wx = wx; + camera_prev_wy = wy; + camera_has_prev = true; + + if (camera_pending_dx != 0 || camera_pending_dy != 0) { + if (std::abs(camera_pending_dx) > 6 || std::abs(camera_pending_dy) > 6) { + // Scrolling far outran detection: snap (keep rest, drop the animation debt). + transient_x = 0.0; + transient_y = 0.0; + clear_camera_pending(); + } else { + // Fast scrolling applies the pending delta PIECEMEAL: the buffers may hold +1 of a + // pending +3 this frame. Testing only the total made landings miss, time out, and + // snap -- the fast-scroll jitter. Instead, find the LARGEST applied prefix of the + // pending scroll and attribute just that; the rest keeps pending. Ties between + // qualifying shifts only happen on uniform terrain, where mistiming is invisible. + const int32_t stepx = (camera_pending_dx > 0) - (camera_pending_dx < 0); + const int32_t stepy = (camera_pending_dy > 0) - (camera_pending_dy < 0); + int32_t best_ax = 0, best_ay = 0, best_mag = -1; + double best_score = -1.0; + bool no_data = false; + for (int32_t ix = 0; ix <= std::abs(camera_pending_dx); ++ix) { + for (int32_t iy = 0; iy <= std::abs(camera_pending_dy); ++iy) { + const double score = background_match_ratio(vp, ix * stepx, iy * stepy); + if (score < 0.0) { + no_data = true; + break; + } + const int32_t mag = ix + iy; + if (score >= 0.6 && + (mag > best_mag || (mag == best_mag && score > best_score))) { + best_mag = mag; + best_score = score; + best_ax = ix * stepx; + best_ay = iy * stepy; + } + } + if (no_data) + break; + } + if (no_data) { + // Nothing to compare against (empty background): give up on attribution. + clear_camera_pending(); + } else if (best_mag > 0) { + attribute_landed(best_ax, best_ay, tile); + camera_pending_dx -= best_ax; + camera_pending_dy -= best_ay; + camera_pending_frames = 0; + } else if (best_mag == 0) { + // Content demonstrably hasn't moved yet: keep waiting, no timeout pressure. + camera_pending_frames = 0; + } else if (++camera_pending_frames > 4) { + // Neither static nor any prefix recognizable (heavy simultaneous change): + // drop the debt without touching the in-flight glide. + clear_camera_pending(); + } + } + } + + // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and rests where + // released. DF's own drag still moves window in tile steps; rest carries the remainder. + // Positions are tracked against the CONTENT window (window minus unlanded jumps) so the + // buffer lag never causes a visible stutter. + const bool mbut = enabler != nullptr && enabler->mouse_mbut; + const double content_wx = double(wx - camera_pending_dx); + const double content_wy = double(wy - camera_pending_dy); + if (mbut && !drag_active && gps != nullptr) { + drag_active = true; + drag_anchor_vx = content_wx - rest_x - transient_x / tile; + drag_anchor_vy = content_wy - rest_y - transient_y / tile; + drag_anchor_mx = gps->precise_mouse_x; + drag_anchor_my = gps->precise_mouse_y; + transient_x = 0.0; + transient_y = 0.0; + } + if (drag_active) { + if (!mbut) { + drag_active = false; + normalize_rest(); + } else if (gps != nullptr) { + double vx = drag_anchor_vx - double(gps->precise_mouse_x - drag_anchor_mx) / tile; + double vy = drag_anchor_vy - double(gps->precise_mouse_y - drag_anchor_my) / tile; + rest_x = content_wx - vx; + rest_y = content_wy - vy; + // If DF's own drag disagrees by more than a tile and a half, rebase on its view. + const double lim = 1.5; + if (rest_x < -lim || rest_x > lim || rest_y < -lim || rest_y > lim) { + rest_x = std::clamp(rest_x, -lim, lim); + rest_y = std::clamp(rest_y, -lim, lim); + drag_anchor_vx = + content_wx - rest_x + double(gps->precise_mouse_x - drag_anchor_mx) / tile; + drag_anchor_vy = + content_wy - rest_y + double(gps->precise_mouse_y - drag_anchor_my) / tile; + } + } + } + + if (transient_x != 0.0 || transient_y != 0.0) { + const double k = std::exp(-double(delta_ms) / camera_tau_ms); + transient_x *= k; + transient_y *= k; + if (std::abs(transient_x) < 0.5 && std::abs(transient_y) < 0.5) { + transient_x = 0.0; + transient_y = 0.0; + } + } +} + +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 std::array signature = {window_z ? *window_z : 0, + vp->dim_x, + vp->dim_y, + vp->clipx[0], + vp->clipx[1], + vp->clipy[0], + vp->clipy[1], + renderer->viewport_zoom_factor, + renderer->origin_x, + renderer->origin_y, + gps->dimx, + gps->dimy}; + const bool changed = + !has_view_signature || previous_viewport != vp || previous_view_signature != signature; + if (changed) { + ++visual_context_revision; + previous_coverage.clear(); + cancel_camera_transients(); + } + previous_viewport = vp; + previous_view_signature = signature; + has_view_signature = 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, + vp->dim_x, + vp->dim_y, + visual_context_revision, + visual_layers(const_viewport), + visual_layers(const_viewport, true), + 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; + float source_x; + float source_y; + int32_t target_x; + int32_t target_y; + int32_t texpos; + float progress; + SDL_Texture *texture; + bool mirrored = false; + int32_t mirror_shift = 0; + std::set> coverage; +}; + +using tile_coveragest = std::set>; + +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({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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = visual_layer_at_draw_order(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.center_x && + anchor.target_y == y + descriptor.center_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.center_x, + y + mirror_descriptor.center_y) != native_sprite_facing; + // The anchor's own layer has center_x 0, so it flips in place. + const int32_t mirror_shift = + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.center_x) - x : 0; + render_proxyst proxy = {static_cast(layer), + movement.source_x, + movement.source_y, + 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) { + std::set> mirrored_coverage; + for (const auto &tile : proxy.coverage) + mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); + for (const auto &tile : mirrored_coverage) { + if (!inside_clip(vp, tile.first, tile.second)) { + blocked = true; + break; + } + if (visual_render_group(proxy.layer) == visual_render_groupst::main && + has_fire(vp, tile.first, tile.second)) { + 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { + const viewport_visual_layer visual_layer = + visual_layer_at_draw_order(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.center_x; + const int32_t y = anchor_y - descriptor.center_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), + 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; + update_camera(renderer, vp, animation_manager.get_frame_delta_ms()); + const double cam_tile = tile_px(renderer); + const int32_t glide_x = int32_t(std::lround(transient_x + rest_x * cam_tile)); + const int32_t glide_y = int32_t(std::lround(transient_y + rest_y * cam_tile)); + const bool glide = glide_x != 0 || glide_y != 0; + if (!glide && camera_was_offset) { + // The camera just re-joined the grid: one engine redraw replaces the last shifted frame. + camera_was_offset = false; + if (gps != nullptr) + ++gps->force_full_display_count; + } + if (glide) + camera_was_offset = true; + const bool animation_redraw = + zlevel_enabled ? animation_manager.requires_full_redraw() + : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); + if (!glide && !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); + + if (glide) { + // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the world (and + // the creature proxies, which read origin at draw time) renders between tiles. The engine + // already drew this frame at the snapped position; everything here overdraws it, clipped + // to the map rect so shifted tiles never spill over the UI. The uncovered strip on the + // trailing edge stays black until the glide lands. + const SDL_Rect map_rect = {tile_pixel(vp->clipx[0], renderer->origin_x, zoom), + tile_pixel(vp->clipy[0], renderer->origin_y, zoom), + tile_pixel(vp->clipx[1] + 1, renderer->origin_x, zoom) - + tile_pixel(vp->clipx[0], renderer->origin_x, zoom), + tile_pixel(vp->clipy[1] + 1, renderer->origin_y, zoom) - + tile_pixel(vp->clipy[0], renderer->origin_y, zoom)}; + render_set_clip_rect(sdl_renderer, &map_rect); + 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); + render_fill_rect(sdl_renderer, &map_rect); + set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); + + const int32_t saved_origin_x = renderer->origin_x; + const int32_t saved_origin_y = renderer->origin_y; + renderer->origin_x += glide_x; + renderer->origin_y += glide_y; + for (int32_t x = vp->clipx[0]; x <= vp->clipx[1]; ++x) { + for (int32_t y = vp->clipy[0]; y <= vp->clipy[1]; ++y) + redraw_world_tile(renderer, viewport_renders, coverage, x, y); + } + draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); + renderer->origin_x = saved_origin_x; + renderer->origin_y = saved_origin_y; + render_set_clip_rect(sdl_renderer, nullptr); + + // Everything was repainted; per-tile coverage bookkeeping restarts after the glide. + previous_coverage.clear(); + return; + } + + 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, ()); @@ -29,47 +1130,148 @@ struct renderer_hook : df::renderer_2d_base { IMPLEMENT_VMETHOD_INTERPOSE(renderer_hook, update_all); void renderer_hook::interpose_fn_update_all() { - smooth_movement::render(this); + // 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; + render_set_clip_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_RenderSetClipRect, render_set_clip_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_view_signature = {}; + has_view_signature = false; + previous_pan_x = 0; + previous_pan_y = 0; + has_pan_context = false; + cancel_camera_transients(); + rest_x = 0.0; + rest_y = 0.0; + camera_enabled = false; + camera_has_prev = false; + camera_was_offset = 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", smooth_movement::flip_enabled() ? "on" : "off"); - out.print("lower z-level animation: {}\n", - smooth_movement::zlevel_enabled() ? "on" : "off"); + out.print("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the grid)\n", + camera_enabled ? "on" : "off", -rest_x, -rest_y); + 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] == "camera") { + if (parameters.size() == 1) { + out.print("free camera: {}, offset {:.3f} {:.3f}\n", camera_enabled ? "on" : "off", + -rest_x, -rest_y); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "on") { + set_camera_enabled(true); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "off") { + set_camera_enabled(false); + return CR_OK; + } + if (parameters.size() == 2 && parameters[1] == "reset") { + rest_x = 0.0; + rest_y = 0.0; + return CR_OK; + } + if (parameters.size() == 3) { + try { + const double fx = std::stod(parameters[1]); + const double fy = std::stod(parameters[2]); + if (fx < -0.99 || fx > 0.99 || fy < -0.99 || fy > 0.99) { + out.printerr("offsets must be within -0.99..0.99 tiles\n"); + return CR_FAILURE; + } + // User-facing: positive = view sits east/south of the grid position. + set_camera_enabled(true); + rest_x = -fx; + rest_y = -fy; + normalize_rest(); + return CR_OK; + } catch (...) { + return CR_WRONG_USAGE; + } + } + return CR_WRONG_USAGE; + } if (parameters[0] == "flip") { if (parameters.size() == 1) { - out.print("sprite flipping: {}\n", smooth_movement::flip_enabled() ? "on" : "off"); + out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); return CR_OK; } - if (parameters.size() == 2 && parameters[1] == "on") - smooth_movement::set_flip_enabled(true); - else if (parameters.size() == 2 && parameters[1] == "off") - smooth_movement::set_flip_enabled(false); - else - return CR_WRONG_USAGE; - out.print("smooth-movement: sprite flipping {}\n", - smooth_movement::flip_enabled() ? "enabled" : "disabled"); - 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", - smooth_movement::zlevel_enabled() ? "on" : "off"); + out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); return CR_OK; } if (parameters.size() == 2 && parameters[1] == "on") - smooth_movement::set_zlevel_enabled(true); + zlevel_enabled = true; else if (parameters.size() == 2 && parameters[1] == "off") - smooth_movement::set_zlevel_enabled(false); + 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", - smooth_movement::zlevel_enabled() ? "enabled" : "disabled"); + zlevel_enabled ? "enabled" : "disabled"); return CR_OK; } return CR_WRONG_USAGE; @@ -87,16 +1289,20 @@ DFhackCExport command_result plugin_enable(color_ostream &out, bool enable) { if (is_enabled == enable) return CR_OK; if (enable) { - if (!smooth_movement::initialize(out)) + 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"); - smooth_movement::shutdown(); + clear_sdl_bindings(); return CR_FAILURE; } } else { INTERPOSE_HOOK(renderer_hook, update_all).remove(); - smooth_movement::shutdown(); + reset_state(); + clear_sdl_bindings(); + if (gps != nullptr) + ++gps->force_full_display_count; } is_enabled = enable; out.print("smooth-movement: {}\n", enable ? "enabled" : "disabled"); diff --git a/plugins/smooth-movement/visual_animation.cpp b/plugins/smooth-movement/visual_animation.cpp deleted file mode 100644 index 2fb8c53e83..0000000000 --- a/plugins/smooth-movement/visual_animation.cpp +++ /dev/null @@ -1,581 +0,0 @@ -// SPDX-License-Identifier: MIT - -#include "visual_animation.h" - -void visual_animation_managerst::clear_pending(viewport_animationst &state) { - state.pending.clear(); - state.pending_frames = 0; - state.pending_age = 0; -} - -void visual_animation_managerst::abandon_pending(viewport_animationst &state) { - state.movements.clear(); - clear_pending(state); -} - -void visual_animation_managerst::reset_facing(viewport_animationst &state) { - std::fill(state.facing.begin(), state.facing.end(), int8_t(native_sprite_facing)); - state.has_mirrored = false; -} - -void visual_animation_managerst::reset_tracking(viewport_animationst &state) { - abandon_pending(state); - state.suppress_frames = 0; -} - -uint64_t visual_animation_managerst::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.dim_x * input.dim_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; -} - -double visual_animation_managerst::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.dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t texpos = current[x * input.dim_y + y]; - if (texpos == 0) - continue; - const int32_t sy = y + dwy; - if (sy < 0 || sy >= input.dim_y) - continue; - ++considered; - if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) - ++matches; - } - } - } - if (considered == 0) - return -1.0; - return double(matches) / double(considered); -} - -std::array visual_animation_managerst::shared_movement_delta(const int32_t *current, - const int32_t *previous, - int32_t dim_x, - int32_t dim_y) { - std::array best{}; - 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 = {dx, dy}; - ambiguous = false; - } else if (count == best_count && count > 1) - ambiguous = true; - } - } - return ambiguous ? std::array{} : best; -} - -visual_animation_managerst::viewport_animationst & -visual_animation_managerst::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 visual_animation_managerst::movement_progress(uint32_t start_time_ms) const { - return animation_progress(frame_time_ms, start_time_ms, movement_duration_ms); -} - -void visual_animation_managerst::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 visual_animation_managerst::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.dim_x != input.dim_x || state.dim_y != input.dim_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.push_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.dim_x = input.dim_x; - state.dim_y = input.dim_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.dim_x) * size_t(input.dim_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; - std::array landed_shift{}; - 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. - std::array landed{}; - size_t landed_count = 0; - bool any_data = false; - std::array shift{}; - for (size_t count = 1; count <= state.pending.size(); ++count) { - shift[0] += state.pending[count - 1][0]; - shift[1] += state.pending[count - 1][1]; - // A prefix netting to zero is indistinguishable from "nothing landed - // yet". Accepting it would retire shifts the buffers have still to apply. - if (shift[0] == 0 && shift[1] == 0) - continue; - const double ratio = shift_match_ratio(input, shift[0], shift[1]); - // 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[0]; - const int32_t dwy = landed[1]; - 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.dim_x || - movement.target_y < 0 || - movement.target_y >= input.dim_y; - }), - state.movements.end()); - // Facing describes creatures still on screen, so translate it rather than - // drop it. - if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { - std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); - for (int32_t x = 0; x < input.dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t sy = y + dwy; - if (sy < 0 || sy >= input.dim_y) - continue; - shifted[x * input.dim_y + y] = state.facing[sx * input.dim_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.dim_x) * size_t(input.dim_y), 0); - for (int32_t x = 0; x < input.dim_x; ++x) { - const int32_t sx = x + landed_shift[0]; - if (sx < 0 || sx >= input.dim_x) - continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t sy = y + landed_shift[1]; - if (sy < 0 || sy >= input.dim_y) - continue; - rebased_previous[layer][x * input.dim_y + y] = - input.previous[layer][sx * input.dim_y + sy]; - } - } - previous_layers[layer] = rebased_previous[layer].data(); - } - } - if (!suppress) { - const int32_t tile_count = input.dim_x * input.dim_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.dim_x) * size_t(input.dim_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.dim_x, input.dim_y) - : std::array{}; - for (int32_t x = 0; x < input.dim_x; ++x) { - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t target = x * input.dim_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[0] != 0 || shared_delta[1] != 0) { - const int32_t source_x = x + shared_delta[0]; - const int32_t source_y = y + shared_delta[1]; - if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && - source_y < input.dim_y) { - const int32_t candidate = source_x * input.dim_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.dim_x || source_y < 0 || - source_y >= input.dim_y) - continue; - const int32_t candidate = source_x * input.dim_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.dim_y); - float visual_source_y = float(source % input.dim_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, x, y, - frame_time_ms}); - if (static_cast(layer) == - viewport_visual_layer::center && - state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y) && - !facing_at_frame_start.empty() && - facing_at_frame_start.size() == state.facing.size()) { - const int32_t source_tile_x = source / input.dim_y; - const int32_t target_index = x * input.dim_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.dim_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.dim_x) * size_t(input.dim_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 visual_animation_managerst::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 visual_animation_managerst::get_frame_time_ms() const { return frame_time_ms; } - -uint32_t visual_animation_managerst::get_frame_delta_ms() const { return frame_delta_ms; } - -visual_facingst visual_animation_managerst::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.dim_x || y < 0 || y >= state.dim_y) - break; - const size_t index = size_t(x) * size_t(state.dim_y) + size_t(y); - if (index >= state.facing.size()) - break; - return static_cast(state.facing[index]); - } - return native_sprite_facing; -} - -bool visual_animation_managerst::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 visual_animation_managerst::requires_full_redraw() const { return force_full_redraw; } - -bool visual_animation_managerst::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 visual_animation_managerst::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_x, movement.source_y, - 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 {}; -} diff --git a/plugins/smooth-movement/visual_animation.h b/plugins/smooth-movement/visual_animation.h index b163fe1e1f..5c0153eb5b 100644 --- a/plugins/smooth-movement/visual_animation.h +++ b/plugins/smooth-movement/visual_animation.h @@ -108,9 +108,8 @@ struct viewport_visual_animation_inputst { 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. + // 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. int32_t pan_x = 0; int32_t pan_y = 0; @@ -151,8 +150,7 @@ inline int32_t inherited_visual_source_tile(int32_t overlay_target, float center 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. +// 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. @@ -164,8 +162,7 @@ constexpr visual_facingst facing_after_move(int32_t dx, visual_facingst previous return previous; } -// center_x is only -1, 0 or +1, so a creature is at most three columns wide -// here. +// center_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); } @@ -189,31 +186,26 @@ class visual_animation_managerst { 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. + // 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. + // Stationary mirrored creatures are repainted every frame; this is the cheap pre-check. bool has_mirrored = false; int32_t pan_x = 0; int32_t pan_y = 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. + // 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. + // 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. + // Set while the previous buffer still belongs to a view that has been left behind. bool previous_view_stale = false; }; @@ -224,61 +216,578 @@ class visual_animation_managerst { 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. + // 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. + // 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); + 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); + static void abandon_pending(viewport_animationst &state) { + state.movements.clear(); + clear_pending(state); + } - static void reset_facing(viewport_animationst &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); + 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); + // 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.dim_x * input.dim_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. + // 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 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.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t texpos = current[x * input.dim_y + y]; + if (texpos == 0) + continue; + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + ++considered; + if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) + ++matches; + } + } + } + if (considered == 0) + return -1.0; + return double(matches) / double(considered); + } static std::array shared_movement_delta(const int32_t *current, const int32_t *previous, int32_t dim_x, - int32_t dim_y); + int32_t dim_y) { + std::array best{}; + 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 = {dx, dy}; + ambiguous = false; + } else if (count == best_count && count > 1) + ambiguous = true; + } + } + return ambiguous ? std::array{} : best; + } - viewport_animationst &get_viewport(const viewport_visual_animation_inputst &input); + 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; + 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); + 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); + 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; + } - void end_frame(); + // 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.dim_x != input.dim_x || state.dim_y != input.dim_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.push_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.dim_x = input.dim_x; + state.dim_y = input.dim_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.dim_x) * size_t(input.dim_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; + } - uint32_t get_frame_time_ms() const; + // 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; + std::array landed_shift{}; + 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. + std::array landed{}; + size_t landed_count = 0; + bool any_data = false; + std::array shift{}; + for (size_t count = 1; count <= state.pending.size(); ++count) { + shift[0] += state.pending[count - 1][0]; + shift[1] += state.pending[count - 1][1]; + // A prefix netting to zero is indistinguishable from "nothing landed yet". + // Accepting it would retire shifts the buffers have still to apply. + if (shift[0] == 0 && shift[1] == 0) + continue; + const double ratio = shift_match_ratio(input, shift[0], shift[1]); + // 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[0]; + const int32_t dwy = landed[1]; + 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.dim_x || + movement.target_y < 0 || + movement.target_y >= input.dim_y; + }), + state.movements.end()); + // Facing describes creatures still on screen, so translate it rather than drop it. + if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { + std::vector shifted(state.facing.size(), int8_t(native_sprite_facing)); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + dwx; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + dwy; + if (sy < 0 || sy >= input.dim_y) + continue; + shifted[x * input.dim_y + y] = state.facing[sx * input.dim_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.dim_x) * size_t(input.dim_y), 0); + for (int32_t x = 0; x < input.dim_x; ++x) { + const int32_t sx = x + landed_shift[0]; + if (sx < 0 || sx >= input.dim_x) + continue; + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t sy = y + landed_shift[1]; + if (sy < 0 || sy >= input.dim_y) + continue; + rebased_previous[layer][x * input.dim_y + y] = + input.previous[layer][sx * input.dim_y + sy]; + } + } + previous_layers[layer] = rebased_previous[layer].data(); + } + } + if (!suppress) { + const int32_t tile_count = input.dim_x * input.dim_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.dim_x) * size_t(input.dim_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.dim_x, input.dim_y) + : std::array{}; + for (int32_t x = 0; x < input.dim_x; ++x) { + for (int32_t y = 0; y < input.dim_y; ++y) { + const int32_t target = x * input.dim_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[0] != 0 || shared_delta[1] != 0) { + const int32_t source_x = x + shared_delta[0]; + const int32_t source_y = y + shared_delta[1]; + if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && + source_y < input.dim_y) { + const int32_t candidate = source_x * input.dim_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.dim_x || source_y < 0 || + source_y >= input.dim_y) + continue; + const int32_t candidate = source_x * input.dim_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.dim_y); + float visual_source_y = float(source % input.dim_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, x, y, + frame_time_ms}); + if (static_cast(layer) == + viewport_visual_layer::center && + state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y) && + !facing_at_frame_start.empty() && + facing_at_frame_start.size() == state.facing.size()) { + const int32_t source_tile_x = source / input.dim_y; + const int32_t target_index = x * input.dim_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.dim_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.dim_x) * size_t(input.dim_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_delta_ms() const; + uint32_t get_frame_time_ms() const { return frame_time_ms; } - visual_facingst get_facing(const void *viewport, int32_t x, int32_t y) const; + uint32_t get_frame_delta_ms() const { return frame_delta_ms; } - bool has_mirrored_facing(const void *viewport) const; + 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.dim_x || y < 0 || y >= state.dim_y) + break; + const size_t index = size_t(x) * size_t(state.dim_y) + size_t(y); + if (index >= state.facing.size()) + break; + return static_cast(state.facing[index]); + } + return native_sprite_facing; + } - bool requires_full_redraw() const; + 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 has_active_movement(const void *viewport) const; + 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; + 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_x, movement.source_y, + 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 diff --git a/plugins/smooth-movement/visual_renderer.cpp b/plugins/smooth-movement/visual_renderer.cpp deleted file mode 100644 index 1b62451436..0000000000 --- a/plugins/smooth-movement/visual_renderer.cpp +++ /dev/null @@ -1,890 +0,0 @@ -// SPDX-License-Identifier: MIT - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "Core.h" -#include "MemAccess.h" -#include "PluginManager.h" - -#include "modules/DFSDL.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" -#include "visual_renderer.h" - -using namespace DFHack; - -using df::global::gps; -using df::global::window_x; -using df::global::window_y; -using df::global::window_z; - -namespace { - -// 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; -std::set> previous_coverage; -uint64_t visual_context_revision = 0; -const void *previous_viewport = nullptr; -std::array previous_view_signature{}; -bool has_view_signature = 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. -int32_t previous_pan_x = 0; -int32_t previous_pan_y = 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 std::array signature = {window_z ? *window_z : 0, - vp->dim_x, - vp->dim_y, - vp->clipx[0], - vp->clipx[1], - vp->clipy[0], - vp->clipy[1], - renderer->viewport_zoom_factor, - renderer->origin_x, - renderer->origin_y, - gps->dimx, - gps->dimy}; - const bool changed = - !has_view_signature || previous_viewport != vp || previous_view_signature != signature; - if (changed) { - ++visual_context_revision; - previous_coverage.clear(); - } - previous_viewport = vp; - previous_view_signature = signature; - has_view_signature = 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, - vp->dim_x, - vp->dim_y, - visual_context_revision, - visual_layers(const_viewport), - visual_layers(const_viewport, true), - 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; - float source_x; - float source_y; - int32_t target_x; - int32_t target_y; - int32_t texpos; - float progress; - SDL_Texture *texture; - bool mirrored = false; - int32_t mirror_shift = 0; - std::set> coverage; -}; - -using tile_coveragest = std::set>; - -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({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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = visual_layer_at_draw_order(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.center_x && - anchor.target_y == y + descriptor.center_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.center_x, - y + mirror_descriptor.center_y) != native_sprite_facing; - // The anchor's own layer has center_x 0, so it flips in place. - const int32_t mirror_shift = - mirrored ? mirrored_tile_x(x, x + mirror_descriptor.center_x) - x : 0; - render_proxyst proxy = {static_cast(layer), - movement.source_x, - movement.source_y, - 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) { - std::set> mirrored_coverage; - for (const auto &tile : proxy.coverage) - mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); - for (const auto &tile : mirrored_coverage) { - if (!inside_clip(vp, tile.first, tile.second)) { - blocked = true; - break; - } - if (visual_render_group(proxy.layer) == visual_render_groupst::main && - has_fire(vp, tile.first, tile.second)) { - 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = - visual_layer_at_draw_order(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.center_x; - const int32_t y = anchor_y - descriptor.center_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), - 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); -} - -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_view_signature = {}; - has_view_signature = false; - previous_pan_x = 0; - previous_pan_y = 0; - has_pan_context = false; - flip_enabled = false; - zlevel_enabled = false; -} - -} // namespace - -namespace smooth_movement { - -bool initialize(color_ostream &out) { - reset_state(); - return load_sdl(out); -} - -void shutdown() { - reset_state(); - clear_sdl_bindings(); - if (gps != nullptr) - ++gps->force_full_display_count; -} - -void render(df::renderer_2d_base *renderer) { render_interpolated_world(renderer); } - -bool flip_enabled() { return ::flip_enabled; } - -void set_flip_enabled(bool enable) { - ::flip_enabled = enable; - if (gps != nullptr) - ++gps->force_full_display_count; -} - -bool zlevel_enabled() { return ::zlevel_enabled; } - -void set_zlevel_enabled(bool enable) { - ::zlevel_enabled = enable; - if (gps != nullptr) - ++gps->force_full_display_count; -} - -} // namespace smooth_movement diff --git a/plugins/smooth-movement/visual_renderer.h b/plugins/smooth-movement/visual_renderer.h deleted file mode 100644 index 147fb6f3fe..0000000000 --- a/plugins/smooth-movement/visual_renderer.h +++ /dev/null @@ -1,24 +0,0 @@ -// SPDX-License-Identifier: MIT - -#pragma once - -namespace DFHack { -class color_ostream; -} - -namespace df { -struct renderer_2d_base; -} - -namespace smooth_movement { - -bool initialize(DFHack::color_ostream &out); -void shutdown(); -void render(df::renderer_2d_base *renderer); - -bool flip_enabled(); -void set_flip_enabled(bool enable); -bool zlevel_enabled(); -void set_zlevel_enabled(bool enable); - -} // namespace smooth_movement From 290a79e568dc7b62188c2f2e771282e1445d3986 Mon Sep 17 00:00:00 2001 From: notliad Date: Thu, 13 Aug 2026 18:23:54 -0300 Subject: [PATCH 4/6] refactor(smooth-movement): address review feedback --- plugins/smooth-movement/smooth-movement.cpp | 531 +++++++++--------- .../test_visual_animation_manager.cpp | 191 ++++--- plugins/smooth-movement/visual_animation.h | 468 ++++++++------- 3 files changed, 654 insertions(+), 536 deletions(-) diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp index aadaf44ad7..09042ee73a 100644 --- a/plugins/smooth-movement/smooth-movement.cpp +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -20,6 +20,7 @@ #include "modules/DFSDL.h" +#include "df/coord2d.h" #include "df/enabler.h" #include "df/graphic.h" #include "df/graphic_viewportst.h" @@ -41,7 +42,10 @@ REQUIRE_GLOBAL(window_z); namespace { -// Runtime harness for the engine-owned visual state; gameplay data is never read. +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; @@ -50,53 +54,62 @@ decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; visual_animation_managerst animation_manager; -std::set> previous_coverage; +tile_coveragest previous_coverage; uint64_t visual_context_revision = 0; const void *previous_viewport = nullptr; -std::array previous_view_signature{}; -bool has_view_signature = 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. -int32_t previous_pan_x = 0; -int32_t previous_pan_y = 0; +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; -// --- free camera ------------------------------------------------------------------------------- +// --- free camera +// ------------------------------------------------------------------------------- // The camera is visually unbound from the tile grid. Two layered offsets: -// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by pixel-perfect -// middle-mouse drag panning (the view rests wherever released, mid-tile or not) -// and by the `smooth-movement camera ` console command. Survives zoom -// and z-level changes. Kept in [-0.5,0.5] by normalization: whole-tile parts are -// folded into window_x/window_y (a plain UI scroll write -- NEVER the viewport -// dims, which crash DF; the sub-tile strip this leaves at one screen edge has no -// buffer data and stays black). -// transient -- the decaying scroll glide from before, in pixels, layered on top. -// Render offset = transient + rest*tile. window_x/window_y remain the game's own tile camera. +// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by +// pixel-perfect +// middle-mouse drag panning (the view rests wherever released, +// mid-tile or not) and by the `smooth-movement camera ` +// console command. Survives zoom and z-level changes. Kept in +// [-0.5,0.5] by normalization: whole-tile parts are folded into +// window_x/window_y (a plain UI scroll write -- NEVER the +// viewport dims, which crash DF; the sub-tile strip this leaves +// at one screen edge has no buffer data and stays black). +// transient -- the decaying scroll glide from before, in pixels, layered on +// top. +// Render offset = transient + rest*tile. window_x/window_y remain the game's +// own tile camera. bool camera_enabled = false; // OFF by default: plain `enable smooth-movement` // keeps upstream behavior (creature interpolation // only); `smooth-movement camera on` opts in. constexpr int32_t camera_max_glide_tiles = 3; // per-jump: farther than this snaps instantly constexpr double camera_tau_ms = 35.0; // transient catch-up (~95% done after 100ms) -double transient_x = 0.0; // decaying glide offset, pixels -double transient_y = 0.0; -double rest_x = 0.0; // persistent free-camera offset, tiles -double rest_y = 0.0; // (positive = view sits WEST/NORTH of window) -int32_t camera_pending_dx = 0; // scroll delta announced but not yet in the buffers -int32_t camera_pending_dy = 0; +point2dst transient; // decaying glide offset, pixels +point2dst rest; // persistent free-camera offset, tiles +df::coord2d camera_pending = df::coord2d(0, 0); // scroll delta not yet in the buffers int32_t camera_pending_frames = 0; -int32_t self_scroll_x = 0; // window deltas WE wrote: visual no-ops when landing -int32_t self_scroll_y = 0; +df::coord2d self_scroll = df::coord2d(0, 0); // window deltas WE wrote: visual no-ops when landing bool drag_active = false; -double drag_anchor_vx = 0.0; // visual camera at drag start, tiles -double drag_anchor_vy = 0.0; -int32_t drag_anchor_mx = 0; // precise mouse at drag start, pixels -int32_t drag_anchor_my = 0; -bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw -int32_t camera_prev_wx = 0; // window-scroll observation baseline -int32_t camera_prev_wy = 0; +point2dst drag_anchor_view; // visual camera at drag start, tiles +df::coord2d drag_anchor_mouse = df::coord2d(0, 0); // precise mouse at drag start, pixels +bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw +df::coord2d camera_previous_window = df::coord2d(0, 0); // window-scroll observation baseline bool camera_has_prev = false; double tile_px(const df::renderer_2d_base *renderer) { @@ -104,8 +117,8 @@ double tile_px(const df::renderer_2d_base *renderer) { return double(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); } -// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, or -1 when there -// is nothing to compare (empty background). +// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, +// or -1 when there is nothing to compare (empty background). double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int32_t dwy) { int32_t considered = 0; int32_t matches = 0; @@ -130,19 +143,20 @@ double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int return double(matches) / double(considered); } -// Cancel everything except the persistent rest offset (the camera keeps its sub-tile position -// across zoom/z/resize; only the in-flight animation state is unfollowable). +// Cancel everything except the persistent rest offset (the camera keeps its +// sub-tile position across zoom/z/resize; only the in-flight animation state is +// unfollowable). void clear_camera_pending() { - camera_pending_dx = 0; - camera_pending_dy = 0; + camera_pending.x = 0; + camera_pending.y = 0; camera_pending_frames = 0; - self_scroll_x = 0; - self_scroll_y = 0; + self_scroll.x = 0; + self_scroll.y = 0; } void cancel_camera_transients() { - transient_x = 0.0; - transient_y = 0.0; + transient.x = 0.0; + transient.y = 0.0; clear_camera_pending(); drag_active = false; } @@ -152,57 +166,61 @@ void set_camera_enabled(bool enable) { return; camera_enabled = enable; cancel_camera_transients(); - rest_x = 0.0; - rest_y = 0.0; + rest.x = 0.0; + rest.y = 0.0; camera_has_prev = false; // fresh observation baseline; no phantom scroll on re-enable - // camera_was_offset stays: the render path issues one cleanup redraw if we were mid-offset. + // camera_was_offset stays: the render path issues one cleanup redraw if we + // were mid-offset. } -// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal edge strip). The -// visual position is unchanged: the window write is attributed via self_scroll when it lands. +// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal +// edge strip). The visual position is unchanged: the window write is attributed +// via self_scroll when it lands. void normalize_rest() { - const int32_t kx = int32_t(-std::llround(rest_x)); - const int32_t ky = int32_t(-std::llround(rest_y)); + const int32_t kx = int32_t(-std::llround(rest.x)); + const int32_t ky = int32_t(-std::llround(rest.y)); if (kx != 0 && window_x != nullptr && *window_x + kx >= 0) { *window_x += kx; - self_scroll_x += kx; + self_scroll.x += kx; } if (ky != 0 && window_y != nullptr && *window_y + ky >= 0) { *window_y += ky; - self_scroll_y += ky; + self_scroll.y += ky; } } -// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization writes are visual -// no-ops (they move into rest); the remainder is a real scroll and glides -- unless a drag is -// driving the position directly, in which case it folds into rest wholesale. +// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization +// writes are visual no-ops (they move into rest); the remainder is a real +// scroll and glides -- unless a drag is driving the position directly, in which +// case it folds into rest wholesale. void attribute_landed(int32_t ax, int32_t ay, double tile) { int32_t sx = 0; - if (self_scroll_x != 0 && (self_scroll_x > 0) == (ax > 0) && ax != 0) - sx = (std::abs(self_scroll_x) <= std::abs(ax)) ? self_scroll_x : ax; + if (self_scroll.x != 0 && (self_scroll.x > 0) == (ax > 0) && ax != 0) + sx = (std::abs(self_scroll.x) <= std::abs(ax)) ? self_scroll.x : ax; int32_t sy = 0; - if (self_scroll_y != 0 && (self_scroll_y > 0) == (ay > 0) && ay != 0) - sy = (std::abs(self_scroll_y) <= std::abs(ay)) ? self_scroll_y : ay; - self_scroll_x -= sx; - self_scroll_y -= sy; - rest_x += sx; - rest_y += sy; + if (self_scroll.y != 0 && (self_scroll.y > 0) == (ay > 0) && ay != 0) + sy = (std::abs(self_scroll.y) <= std::abs(ay)) ? self_scroll.y : ay; + self_scroll.x -= sx; + self_scroll.y -= sy; + rest.x += sx; + rest.y += sy; const int32_t gx = ax - sx; const int32_t gy = ay - sy; if (drag_active) { - rest_x += gx; - rest_y += gy; + rest.x += gx; + rest.y += gy; } else { - transient_x += gx * tile; - transient_y += gy * tile; + transient.x += gx * tile; + transient.y += gy * tile; const double cap = tile * (camera_max_glide_tiles + 0.5); - transient_x = std::clamp(transient_x, -cap, cap); - transient_y = std::clamp(transient_y, -cap, cap); + transient.x = std::clamp(transient.x, -cap, cap); + transient.y = std::clamp(transient.y, -cap, cap); } } -// Per-frame camera bookkeeping: observe window scrolls, attribute them when the buffers apply -// them (glide vs our own normalization writes), drive the drag, decay the transient. +// Per-frame camera bookkeeping: observe window scrolls, attribute them when the +// buffers apply them (glide vs our own normalization writes), drive the drag, +// decay the transient. void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst *vp, uint32_t delta_ms) { if (!camera_enabled) @@ -210,41 +228,43 @@ void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst const double tile = tile_px(renderer); const int32_t wx = window_x ? *window_x : 0; const int32_t wy = window_y ? *window_y : 0; - if (camera_has_prev && (wx != camera_prev_wx || wy != camera_prev_wy)) { - const int32_t dx = wx - camera_prev_wx; - const int32_t dy = wy - camera_prev_wy; + if (camera_has_prev && (wx != camera_previous_window.x || wy != camera_previous_window.y)) { + const int32_t dx = wx - camera_previous_window.x; + const int32_t dy = wy - camera_previous_window.y; if ((std::abs(dx) > camera_max_glide_tiles || std::abs(dy) > camera_max_glide_tiles) && !drag_active) cancel_camera_transients(); // teleport-like jump (recenter/minimap): snap else { - camera_pending_dx += dx; - camera_pending_dy += dy; + camera_pending.x += dx; + camera_pending.y += dy; camera_pending_frames = 0; } } - camera_prev_wx = wx; - camera_prev_wy = wy; + camera_previous_window.x = wx; + camera_previous_window.y = wy; camera_has_prev = true; - if (camera_pending_dx != 0 || camera_pending_dy != 0) { - if (std::abs(camera_pending_dx) > 6 || std::abs(camera_pending_dy) > 6) { - // Scrolling far outran detection: snap (keep rest, drop the animation debt). - transient_x = 0.0; - transient_y = 0.0; + if (camera_pending.x != 0 || camera_pending.y != 0) { + if (std::abs(camera_pending.x) > 6 || std::abs(camera_pending.y) > 6) { + // Scrolling far outran detection: snap (keep rest, drop the animation + // debt). + transient.x = 0.0; + transient.y = 0.0; clear_camera_pending(); } else { - // Fast scrolling applies the pending delta PIECEMEAL: the buffers may hold +1 of a - // pending +3 this frame. Testing only the total made landings miss, time out, and - // snap -- the fast-scroll jitter. Instead, find the LARGEST applied prefix of the - // pending scroll and attribute just that; the rest keeps pending. Ties between - // qualifying shifts only happen on uniform terrain, where mistiming is invisible. - const int32_t stepx = (camera_pending_dx > 0) - (camera_pending_dx < 0); - const int32_t stepy = (camera_pending_dy > 0) - (camera_pending_dy < 0); + // Fast scrolling applies the pending delta PIECEMEAL: the buffers may + // hold +1 of a pending +3 this frame. Testing only the total made + // landings miss, time out, and snap -- the fast-scroll jitter. Instead, + // find the LARGEST applied prefix of the pending scroll and attribute + // just that; the rest keeps pending. Ties between qualifying shifts only + // happen on uniform terrain, where mistiming is invisible. + const int32_t stepx = (camera_pending.x > 0) - (camera_pending.x < 0); + const int32_t stepy = (camera_pending.y > 0) - (camera_pending.y < 0); int32_t best_ax = 0, best_ay = 0, best_mag = -1; double best_score = -1.0; bool no_data = false; - for (int32_t ix = 0; ix <= std::abs(camera_pending_dx); ++ix) { - for (int32_t iy = 0; iy <= std::abs(camera_pending_dy); ++iy) { + for (int32_t ix = 0; ix <= std::abs(camera_pending.x); ++ix) { + for (int32_t iy = 0; iy <= std::abs(camera_pending.y); ++iy) { const double score = background_match_ratio(vp, ix * stepx, iy * stepy); if (score < 0.0) { no_data = true; @@ -263,69 +283,75 @@ void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst break; } if (no_data) { - // Nothing to compare against (empty background): give up on attribution. + // Nothing to compare against (empty background): give up on + // attribution. clear_camera_pending(); } else if (best_mag > 0) { attribute_landed(best_ax, best_ay, tile); - camera_pending_dx -= best_ax; - camera_pending_dy -= best_ay; + camera_pending.x -= best_ax; + camera_pending.y -= best_ay; camera_pending_frames = 0; } else if (best_mag == 0) { - // Content demonstrably hasn't moved yet: keep waiting, no timeout pressure. + // Content demonstrably hasn't moved yet: keep waiting, no timeout + // pressure. camera_pending_frames = 0; } else if (++camera_pending_frames > 4) { - // Neither static nor any prefix recognizable (heavy simultaneous change): - // drop the debt without touching the in-flight glide. + // Neither static nor any prefix recognizable (heavy simultaneous + // change): drop the debt without touching the in-flight glide. clear_camera_pending(); } } } - // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and rests where - // released. DF's own drag still moves window in tile steps; rest carries the remainder. - // Positions are tracked against the CONTENT window (window minus unlanded jumps) so the - // buffer lag never causes a visible stutter. + // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and + // rests where released. DF's own drag still moves window in tile steps; rest + // carries the remainder. Positions are tracked against the CONTENT window + // (window minus unlanded jumps) so the buffer lag never causes a visible + // stutter. const bool mbut = enabler != nullptr && enabler->mouse_mbut; - const double content_wx = double(wx - camera_pending_dx); - const double content_wy = double(wy - camera_pending_dy); + const double content_wx = double(wx - camera_pending.x); + const double content_wy = double(wy - camera_pending.y); if (mbut && !drag_active && gps != nullptr) { drag_active = true; - drag_anchor_vx = content_wx - rest_x - transient_x / tile; - drag_anchor_vy = content_wy - rest_y - transient_y / tile; - drag_anchor_mx = gps->precise_mouse_x; - drag_anchor_my = gps->precise_mouse_y; - transient_x = 0.0; - transient_y = 0.0; + drag_anchor_view.x = content_wx - rest.x - transient.x / tile; + drag_anchor_view.y = content_wy - rest.y - transient.y / tile; + drag_anchor_mouse.x = gps->precise_mouse_x; + drag_anchor_mouse.y = gps->precise_mouse_y; + transient.x = 0.0; + transient.y = 0.0; } if (drag_active) { if (!mbut) { drag_active = false; normalize_rest(); } else if (gps != nullptr) { - double vx = drag_anchor_vx - double(gps->precise_mouse_x - drag_anchor_mx) / tile; - double vy = drag_anchor_vy - double(gps->precise_mouse_y - drag_anchor_my) / tile; - rest_x = content_wx - vx; - rest_y = content_wy - vy; - // If DF's own drag disagrees by more than a tile and a half, rebase on its view. + double vx = + drag_anchor_view.x - double(gps->precise_mouse_x - drag_anchor_mouse.x) / tile; + double vy = + drag_anchor_view.y - double(gps->precise_mouse_y - drag_anchor_mouse.y) / tile; + rest.x = content_wx - vx; + rest.y = content_wy - vy; + // If DF's own drag disagrees by more than a tile and a half, rebase on + // its view. const double lim = 1.5; - if (rest_x < -lim || rest_x > lim || rest_y < -lim || rest_y > lim) { - rest_x = std::clamp(rest_x, -lim, lim); - rest_y = std::clamp(rest_y, -lim, lim); - drag_anchor_vx = - content_wx - rest_x + double(gps->precise_mouse_x - drag_anchor_mx) / tile; - drag_anchor_vy = - content_wy - rest_y + double(gps->precise_mouse_y - drag_anchor_my) / tile; + if (rest.x < -lim || rest.x > lim || rest.y < -lim || rest.y > lim) { + rest.x = std::clamp(rest.x, -lim, lim); + rest.y = std::clamp(rest.y, -lim, lim); + drag_anchor_view.x = + content_wx - rest.x + double(gps->precise_mouse_x - drag_anchor_mouse.x) / tile; + drag_anchor_view.y = + content_wy - rest.y + double(gps->precise_mouse_y - drag_anchor_mouse.y) / tile; } } } - if (transient_x != 0.0 || transient_y != 0.0) { + if (transient.x != 0.0 || transient.y != 0.0) { const double k = std::exp(-double(delta_ms) / camera_tau_ms); - transient_x *= k; - transient_y *= k; - if (std::abs(transient_x) < 0.5 && std::abs(transient_y) < 0.5) { - transient_x = 0.0; - transient_y = 0.0; + transient.x *= k; + transient.y *= k; + if (std::abs(transient.x) < 0.5 && std::abs(transient.y) < 0.5) { + transient.x = 0.0; + transient.y = 0.0; } } } @@ -333,39 +359,38 @@ void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst 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 std::array signature = {window_z ? *window_z : 0, - vp->dim_x, - vp->dim_y, - vp->clipx[0], - vp->clipx[1], - vp->clipy[0], - vp->clipy[1], - renderer->viewport_zoom_factor, - renderer->origin_x, - renderer->origin_y, - gps->dimx, - gps->dimy}; + // 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_view_signature || previous_viewport != vp || previous_view_signature != signature; + !has_visual_context || previous_viewport != vp || previous_visual_context != context; if (changed) { ++visual_context_revision; previous_coverage.clear(); cancel_camera_transients(); } previous_viewport = vp; - previous_view_signature = signature; - has_view_signature = true; + 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). + // 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) + 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; + previous_pan.x = pan_x; + previous_pan.y = pan_y; has_pan_context = true; } @@ -430,13 +455,11 @@ template auto visual_layers(Viewport *vp, bool previous = fa viewport_visual_animation_inputst animation_input(df::graphic_viewportst *vp) { const df::graphic_viewportst *const_viewport = vp; return {vp, - vp->dim_x, - vp->dim_y, + df::coord2d(vp->dim_x, vp->dim_y), visual_context_revision, visual_layers(const_viewport), visual_layers(const_viewport, true), - window_x ? *window_x : 0, - window_y ? *window_y : 0}; + df::coord2d(window_x ? *window_x : 0, window_y ? *window_y : 0)}; } // The layer buffers are freed and nulled without clearing the active flag. @@ -485,20 +508,16 @@ void with_zeroed_values(const Callback &callback, T &value, Values &...values) { struct render_proxyst { viewport_visual_layer layer; - float source_x; - float source_y; - int32_t target_x; - int32_t target_y; + point2dst source; + df::coord2d target; int32_t texpos; float progress; SDL_Texture *texture; bool mirrored = false; int32_t mirror_shift = 0; - std::set> coverage; + tile_coveragest coverage; }; -using tile_coveragest = std::set>; - struct render_coveragest { tile_coveragest all; std::array(visual_render_groupst::count)> groups; @@ -566,15 +585,16 @@ void redraw_viewport_tile(df::renderer_2d_base *renderer, const viewport_renders 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. + // 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. +// 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 && @@ -586,7 +606,8 @@ bool interface_pass_readable(const df::graphic_viewportst *vp) { vp->screentexpos_high_flow != nullptr && vp->screentexpos_signpost != nullptr; } -// Runs after the proxies so the shading covers them rather than sitting underneath. +// 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)) @@ -610,9 +631,9 @@ void draw_interface_only(df::renderer_2d_base *renderer, df::graphic_viewportst 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({x, y}) != 0; + // 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); @@ -641,8 +662,8 @@ void redraw_above(df::renderer_2d_base *renderer, df::graphic_viewportst *vp, in 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. + // 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 @@ -672,8 +693,9 @@ SDL_Texture *cached_texture(df::renderer_2d_base *renderer, int32_t texpos, : 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. +// 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) { @@ -686,11 +708,11 @@ void render_copy_maybe_mirrored(SDL_Renderer *renderer, SDL_Texture *texture, 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 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 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, @@ -704,8 +726,7 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, std::vector proxies; auto layers = visual_layers(vp); auto previous_layers = visual_layers(vp, true); - for (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = visual_layer_at_draw_order(draw_order); + 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) { @@ -720,9 +741,9 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, if (!movement.active) continue; const int32_t inherited_source_x = - inherited_visual_source_tile(x, movement.source_x, 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); + 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; @@ -730,10 +751,10 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, 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 && + 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; } @@ -765,10 +786,10 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, bool owns_fragment = false; for (const render_proxyst &anchor : proxies) if (anchor.layer == viewport_visual_layer::center && - anchor.target_x == x + descriptor.center_x && - anchor.target_y == y + descriptor.center_y && - anchor.source_x - anchor.target_x == movement.source_x - x && - anchor.source_y - anchor.target_y == movement.source_y - y && + 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) @@ -782,19 +803,18 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, 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. + // 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.center_x, - y + mirror_descriptor.center_y) != native_sprite_facing; - // The anchor's own layer has center_x 0, so it flips in place. + 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.center_x) - x : 0; + mirrored ? mirrored_tile_x(x, x + mirror_descriptor.anchor_offset.x) - x : 0; render_proxyst proxy = {static_cast(layer), - movement.source_x, - movement.source_y, - x, - y, + movement.source, + df::coord2d(x, y), texpos, movement.progress, nullptr, @@ -802,12 +822,12 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, 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)))); + 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)))); + 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; @@ -826,16 +846,16 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, if (blocked) continue; if (proxy.mirror_shift != 0) { - std::set> mirrored_coverage; + tile_coveragest mirrored_coverage; for (const auto &tile : proxy.coverage) - mirrored_coverage.emplace(tile.first + proxy.mirror_shift, tile.second); + mirrored_coverage.emplace(tile.x + proxy.mirror_shift, tile.y); for (const auto &tile : mirrored_coverage) { - if (!inside_clip(vp, tile.first, tile.second)) { + 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.first, tile.second)) { + has_fire(vp, tile.x, tile.y)) { blocked = true; break; } @@ -853,24 +873,23 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, } } - // 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. + // 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 (uint8_t draw_order = 0; draw_order < visual_layer_count; ++draw_order) { - const viewport_visual_layer visual_layer = - visual_layer_at_draw_order(draw_order); + 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.center_x; - const int32_t y = anchor_y - descriptor.center_y; + 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); @@ -879,26 +898,26 @@ std::vector collect_proxies(df::renderer_2d_base *renderer, continue; bool already_drawn = false; for (const render_proxyst &existing : proxies) - if (existing.layer == visual_layer && existing.target_x == x && - existing.target_y == y) + 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. + // source == target at progress 1.0 draws in place, moved only by + // mirror_shift. render_proxyst proxy = {visual_layer, - float(x), - float(y), - x, - y, + {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. + // 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; @@ -929,7 +948,7 @@ render_coveragest collect_coverage(const std::vector &proxies, i 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); + 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()); } @@ -1000,14 +1019,16 @@ 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. + // 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. + // 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); @@ -1038,11 +1059,12 @@ void render_interpolated_world(df::renderer_2d_base *renderer) { return; update_camera(renderer, vp, animation_manager.get_frame_delta_ms()); const double cam_tile = tile_px(renderer); - const int32_t glide_x = int32_t(std::lround(transient_x + rest_x * cam_tile)); - const int32_t glide_y = int32_t(std::lround(transient_y + rest_y * cam_tile)); + const int32_t glide_x = int32_t(std::lround(transient.x + rest.x * cam_tile)); + const int32_t glide_y = int32_t(std::lround(transient.y + rest.y * cam_tile)); const bool glide = glide_x != 0 || glide_y != 0; if (!glide && camera_was_offset) { - // The camera just re-joined the grid: one engine redraw replaces the last shifted frame. + // The camera just re-joined the grid: one engine redraw replaces the last + // shifted frame. camera_was_offset = false; if (gps != nullptr) ++gps->force_full_display_count; @@ -1063,10 +1085,11 @@ void render_interpolated_world(df::renderer_2d_base *renderer) { const int32_t tile_size = zoom == 128 ? 32 : std::max(1, zoom * 32 / 128); if (glide) { - // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the world (and - // the creature proxies, which read origin at draw time) renders between tiles. The engine - // already drew this frame at the snapped position; everything here overdraws it, clipped - // to the map rect so shifted tiles never spill over the UI. The uncovered strip on the + // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the + // world (and the creature proxies, which read origin at draw time) renders + // between tiles. The engine already drew this frame at the snapped + // position; everything here overdraws it, clipped to the map rect so + // shifted tiles never spill over the UI. The uncovered strip on the // trailing edge stays black until the glide lands. const SDL_Rect map_rect = {tile_pixel(vp->clipx[0], renderer->origin_x, zoom), tile_pixel(vp->clipy[0], renderer->origin_y, zoom), @@ -1094,7 +1117,8 @@ void render_interpolated_world(df::renderer_2d_base *renderer) { renderer->origin_y = saved_origin_y; render_set_clip_rect(sdl_renderer, nullptr); - // Everything was repainted; per-tile coverage bookkeeping restarts after the glide. + // Everything was repainted; per-tile coverage bookkeeping restarts after + // the glide. previous_coverage.clear(); return; } @@ -1169,14 +1193,14 @@ void reset_state() { previous_coverage.clear(); visual_context_revision = 0; previous_viewport = nullptr; - previous_view_signature = {}; - has_view_signature = false; - previous_pan_x = 0; - previous_pan_y = 0; + previous_visual_context = {}; + has_visual_context = false; + previous_pan.x = 0; + previous_pan.y = 0; has_pan_context = false; cancel_camera_transients(); - rest_x = 0.0; - rest_y = 0.0; + rest.x = 0.0; + rest.y = 0.0; camera_enabled = false; camera_has_prev = false; camera_was_offset = false; @@ -1187,8 +1211,9 @@ void reset_state() { command_result status_command(color_ostream &out, std::vector ¶meters) { if (parameters.empty()) { out.print("smooth-movement: {}\n", is_enabled ? "enabled" : "disabled"); - out.print("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the grid)\n", - camera_enabled ? "on" : "off", -rest_x, -rest_y); + out.print("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the " + "grid)\n", + camera_enabled ? "on" : "off", -rest.x, -rest.y); out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off"); out.print("lower z-level animation: {}\n", zlevel_enabled ? "on" : "off"); return CR_OK; @@ -1196,7 +1221,7 @@ command_result status_command(color_ostream &out, std::vector ¶ if (parameters[0] == "camera") { if (parameters.size() == 1) { out.print("free camera: {}, offset {:.3f} {:.3f}\n", camera_enabled ? "on" : "off", - -rest_x, -rest_y); + -rest.x, -rest.y); return CR_OK; } if (parameters.size() == 2 && parameters[1] == "on") { @@ -1208,8 +1233,8 @@ command_result status_command(color_ostream &out, std::vector ¶ return CR_OK; } if (parameters.size() == 2 && parameters[1] == "reset") { - rest_x = 0.0; - rest_y = 0.0; + rest.x = 0.0; + rest.y = 0.0; return CR_OK; } if (parameters.size() == 3) { @@ -1222,8 +1247,8 @@ command_result status_command(color_ostream &out, std::vector ¶ } // User-facing: positive = view sits east/south of the grid position. set_camera_enabled(true); - rest_x = -fx; - rest_y = -fy; + rest.x = -fx; + rest.y = -fy; normalize_rest(); return CR_OK; } catch (...) { @@ -1237,10 +1262,10 @@ command_result status_command(color_ostream &out, std::vector ¶ 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. + // 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) diff --git a/plugins/smooth-movement/test_visual_animation_manager.cpp b/plugins/smooth-movement/test_visual_animation_manager.cpp index 293c96cf8e..8bebdee328 100644 --- a/plugins/smooth-movement/test_visual_animation_manager.cpp +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -16,8 +16,7 @@ viewport_visual_animation_inputst make_input(const void *viewport, int32_t dimen const int32_t *empty) { viewport_visual_animation_inputst input; input.viewport = viewport; - input.dim_x = dimension; - input.dim_y = dimension; + input.dimensions = df::coord2d(dimension, dimension); input.context_revision = 1; input.current.fill(empty); input.previous.fill(empty); @@ -71,11 +70,12 @@ int main() { 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. + // The formula is a general reflection, so it holds for offsets no layer can + // express. assert(mirrored_tile_x(8, 5) == 2); - // center_x is only ever -1, 0 or +1, so the real mirror shift is only ever -2, 0 or +2. + // The horizontal anchor offset bounds the real mirror shift to -2, 0 or +2. for (const auto &descriptor : visual_layer_descriptors) - assert(descriptor.center_x >= -1 && descriptor.center_x <= 1); + 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); @@ -105,7 +105,8 @@ int main() { } // Moving east sets east facing. - // East is neither the grid default nor the source facing, so the assertion is not vacuous. + // 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); @@ -120,7 +121,8 @@ int main() { } // 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. + // 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); @@ -172,8 +174,8 @@ int main() { 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. + // 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; @@ -201,8 +203,9 @@ int main() { } } - // 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. + // 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] = {}; @@ -230,8 +233,9 @@ int main() { 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. + // 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); @@ -247,9 +251,10 @@ int main() { 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. + // 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] = {}; @@ -277,12 +282,13 @@ int main() { assert(landed.has_mirrored_facing(pan_viewport)); // Frame A: the scroll is announced but the buffers have not moved yet. - input.pan_x = 1; + 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. + // 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); @@ -290,9 +296,10 @@ int main() { 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. + // 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); @@ -302,16 +309,16 @@ int main() { 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; + // 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. + // 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)); } @@ -322,9 +329,10 @@ int main() { } } - // 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. + // 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] = {}; @@ -339,8 +347,8 @@ int main() { 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. + // 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) @@ -356,14 +364,15 @@ int main() { 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. + // 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. + // 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] = {}; @@ -373,17 +382,19 @@ int main() { 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. + // 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. + // 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 @@ -399,7 +410,8 @@ int main() { 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. + // 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); @@ -408,14 +420,14 @@ int main() { 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).center_x == -1); - assert(visual_layer_descriptor(viewport_visual_layer::left).center_x == 1); - assert(visual_layer_descriptor(viewport_visual_layer::upright).center_x == -1 && - visual_layer_descriptor(viewport_visual_layer::upright).center_y == 1); - assert(visual_layer_descriptor(viewport_visual_layer::up).center_x == 0 && - visual_layer_descriptor(viewport_visual_layer::up).center_y == 1); - assert(visual_layer_descriptor(viewport_visual_layer::upleft).center_x == 1 && - visual_layer_descriptor(viewport_visual_layer::upleft).center_y == 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{}; @@ -433,7 +445,7 @@ int main() { 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.source.x == 0 && render.source.y == 1); assert(render.progress == 0.0f); assert(movement.requires_full_redraw()); @@ -460,7 +472,8 @@ int main() { 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. + // 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); @@ -472,11 +485,11 @@ int main() { 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); + 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. + // 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}) { @@ -504,23 +517,25 @@ int main() { 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. + // 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. + // 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 + 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 @@ -529,57 +544,59 @@ int main() { 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. + // 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; + 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); + assert(moved.active && moved.source.x == 0 && moved.source.y == 1); - pan_input.pan_x = 1; // frame A: pan announced, buffers unchanged + 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 + 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); + assert(followed.active && followed.source.x == -1 && followed.source.y == 1); - // SAME-FRAME: pan announced and buffers shifted in the same call — translated immediately. + // 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; + 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_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); + 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. + // 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.pan.x = 0; pan_input.context_revision = 1; run_frame(reset_on_zoom, pan_input, 8000); pan_previous[0 * 3 + 1] = 42; @@ -590,7 +607,8 @@ int main() { 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. + // Status fragments inherit nearby center motion even while their texture + // flashes. std::array status_current{}; std::array status_previous{}; current.fill(0); @@ -622,7 +640,7 @@ int main() { 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); + 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; @@ -631,7 +649,8 @@ int main() { 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. + // Divergent nearby creature movements make companion ownership ambiguous, so + // the overlay snaps. current.fill(0); previous.fill(0); status_current.fill(0); @@ -651,9 +670,10 @@ int main() { 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); + assert(status.source.x == 1 && status.source.y == 0); - // Wheelbarrows use the item layer and the same independent adjacent-movement detection. + // Wheelbarrows use the item layer and the same independent adjacent-movement + // detection. current.fill(0); previous.fill(0); input.current.fill(empty.data()); @@ -665,9 +685,10 @@ int main() { 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); + 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. + // Minecart graphics can change texpos while moving; vehicle identity is tile + // occupancy. current.fill(0); previous.fill(0); input.current.fill(empty.data()); @@ -689,6 +710,6 @@ int main() { 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 && + 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 index 5c0153eb5b..fd81b20b26 100644 --- a/plugins/smooth-movement/visual_animation.h +++ b/plugins/smooth-movement/visual_animation.h @@ -10,6 +10,13 @@ #include #include +#include "DataDefs.h" + +template struct point2dst { + T x = 0; + T y = 0; +}; + enum class viewport_visual_layer : uint8_t { right, center, @@ -30,55 +37,89 @@ struct visual_layer_descriptorst { visual_render_groupst render_group; bool moves_independently; bool matches_any_previous; - uint8_t draw_order; - int8_t center_x; - int8_t center_y; + point2dst anchor_offset; }; constexpr std::array visual_layer_descriptors = { - visual_layer_descriptorst{viewport_visual_layer::right, visual_render_groupst::main, false, - false, 3, -1, 0}, - visual_layer_descriptorst{viewport_visual_layer::center, visual_render_groupst::main, true, - false, 0, 0, 0}, - visual_layer_descriptorst{viewport_visual_layer::left, visual_render_groupst::main, false, - false, 4, 1, 0}, - visual_layer_descriptorst{viewport_visual_layer::upright, visual_render_groupst::upper, false, - false, 5, -1, 1}, - visual_layer_descriptorst{viewport_visual_layer::up, visual_render_groupst::upper, false, false, - 6, 0, 1}, - visual_layer_descriptorst{viewport_visual_layer::upleft, visual_render_groupst::upper, false, - false, 7, 1, 1}, - visual_layer_descriptorst{viewport_visual_layer::vehicle, visual_render_groupst::vehicle, true, - true, 2, 0, 0}, - visual_layer_descriptorst{viewport_visual_layer::item, visual_render_groupst::item, true, false, - 1, 0, 0}, - visual_layer_descriptorst{viewport_visual_layer::designation, - visual_render_groupst::designation, false, true, 8, 0, 0}}; + 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() { - uint16_t draw_orders = 0; for (size_t i = 0; i < visual_layer_descriptors.size(); ++i) { const auto &descriptor = visual_layer_descriptors[i]; - if (static_cast(descriptor.layer) != i || - descriptor.draw_order >= visual_layer_descriptors.size() || - (draw_orders & (1U << descriptor.draw_order))) + if (static_cast(descriptor.layer) != i) return false; - draw_orders |= uint16_t(1U << descriptor.draw_order); } return true; } static_assert(valid_visual_layer_descriptors()); -constexpr const visual_layer_descriptorst &visual_layer_descriptor(viewport_visual_layer layer) { - return visual_layer_descriptors[static_cast(layer)]; +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); } -constexpr viewport_visual_layer visual_layer_at_draw_order(uint8_t draw_order) { - for (const auto &descriptor : visual_layer_descriptors) - if (descriptor.draw_order == draw_order) - return descriptor.layer; - return viewport_visual_layer::count; +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) { @@ -103,18 +144,17 @@ constexpr bool visual_layer_matches(viewport_visual_layer layer, int32_t current struct viewport_visual_animation_inputst { const void *viewport = nullptr; - int32_t dim_x = 0; - int32_t dim_y = 0; + 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. - int32_t pan_x = 0; - int32_t pan_y = 0; + // 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 || dim_x <= 0 || dim_y <= 0) + 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) @@ -126,8 +166,7 @@ struct viewport_visual_animation_inputst { struct visual_movement_renderst { bool active = false; - float source_x = 0.0f; - float source_y = 0.0f; + point2dst source; float progress = 1.0f; bool inherited = false; }; @@ -150,7 +189,8 @@ inline int32_t inherited_visual_source_tile(int32_t overlay_target, float center 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. +// 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. @@ -162,7 +202,8 @@ constexpr visual_facingst facing_after_move(int32_t dx, visual_facingst previous return previous; } -// center_x is only -1, 0 or +1, so a creature is at most three columns wide here. +// 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); } @@ -171,41 +212,42 @@ class visual_animation_managerst { struct movementst { viewport_visual_layer layer; int32_t texpos; - float source_x; - float source_y; - int32_t target_x; - int32_t target_y; + point2dst source; + df::coord2d target; uint32_t start_time_ms; }; struct viewport_animationst { const void *viewport = nullptr; - int32_t dim_x = 0; - int32_t dim_y = 0; + 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. + // 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. + // Stationary mirrored creatures are repainted every frame; this is the + // cheap pre-check. bool has_mirrored = false; - int32_t pan_x = 0; - int32_t pan_y = 0; + 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; + // 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. + // 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. + // Set while the previous buffer still belongs to a view that has been left + // behind. bool previous_view_stale = false; }; @@ -216,9 +258,11 @@ class visual_animation_managerst { 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. + // 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. + // 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) { @@ -242,13 +286,15 @@ class visual_animation_managerst { state.suppress_frames = 0; } - // Identifies the buffer contents this frame, to tell a redrawn viewport from a repeated one. + // 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. + // 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.dim_x * input.dim_y; + 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; @@ -260,8 +306,8 @@ class visual_animation_managerst { return hash; } - // Fraction of tracked sprites consistent with a buffer shift: current[x]==previous[x+dwx]. - // Negative when there is nothing to compare. + // 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; @@ -270,25 +316,25 @@ class visual_animation_managerst { 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. + // 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.dim_x; ++x) { + for (int32_t x = 0; x < input.dimensions.x; ++x) { const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) + if (sx < 0 || sx >= input.dimensions.x) continue; - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t texpos = current[x * input.dim_y + y]; + 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.dim_y) + if (sy < 0 || sy >= input.dimensions.y) continue; ++considered; - if (visual_layer_matches(id, texpos, previous[sx * input.dim_y + sy])) + if (visual_layer_matches(id, texpos, previous[sx * input.dimensions.y + sy])) ++matches; } } @@ -298,10 +344,9 @@ class visual_animation_managerst { return double(matches) / double(considered); } - static std::array shared_movement_delta(const int32_t *current, - const int32_t *previous, int32_t dim_x, - int32_t dim_y) { - std::array best{}; + 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) { @@ -326,13 +371,13 @@ class visual_animation_managerst { } if (count > best_count) { best_count = count; - best = {dx, dy}; + best = df::coord2d(dx, dy); ambiguous = false; } else if (count == best_count && count > 1) ambiguous = true; } } - return ambiguous ? std::array{} : best; + return ambiguous ? df::coord2d(0, 0) : best; } viewport_animationst &get_viewport(const viewport_visual_animation_inputst &input) { @@ -378,39 +423,43 @@ class visual_animation_managerst { return; } - // Only a replaced view leaves a previous buffer belonging somewhere else; a first - // sighting does not. + // 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.dim_x != input.dim_x || state.dim_y != input.dim_y); + 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)) { + // 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. + // Same give-up as the other two sites: the owed shifts are unknowable + // now. abandon_pending(state); reset_facing(state); } - state.pending.push_back({input.pan_x - state.pan_x, input.pan_y - state.pan_y}); + 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.dim_x = input.dim_x; - state.dim_y = input.dim_y; + 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.pan.x = input.pan.x; + state.pan.y = input.pan.y; state.has_pan = true; if (context_changed) { - state.facing.assign(size_t(input.dim_x) * size_t(input.dim_y), + 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. + // 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; @@ -418,45 +467,50 @@ class visual_animation_managerst { state.has_buffer_signature = true; if (context_changed) { - // Skips the recompute sweep, so clear has_mirrored here or a stale true survives. + // 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. + // 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. + // 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. + // 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; - std::array landed_shift{}; + 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. - std::array landed{}; + // 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; - std::array shift{}; + df::coord2d shift(0, 0); for (size_t count = 1; count <= state.pending.size(); ++count) { - shift[0] += state.pending[count - 1][0]; - shift[1] += state.pending[count - 1][1]; - // A prefix netting to zero is indistinguishable from "nothing landed yet". - // Accepting it would retire shifts the buffers have still to apply. - if (shift[0] == 0 && shift[1] == 0) + 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[0], shift[1]); - // Emptiness is per-prefix: a long one can push every sprite out of range - // while a shorter one still has something to say. + 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; @@ -472,32 +526,36 @@ class visual_animation_managerst { reset_facing(state); } else if (landed_count > 0) { // Re-anchor in-flight movements and drop anything scrolled off-screen. - const int32_t dwx = landed[0]; - const int32_t dwy = landed[1]; - 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.dim_x || - movement.target_y < 0 || - movement.target_y >= input.dim_y; - }), - state.movements.end()); - // Facing describes creatures still on screen, so translate it rather than drop it. - if (state.facing.size() == size_t(input.dim_x) * size_t(input.dim_y)) { + 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.dim_x; ++x) { + for (int32_t x = 0; x < input.dimensions.x; ++x) { const int32_t sx = x + dwx; - if (sx < 0 || sx >= input.dim_x) + if (sx < 0 || sx >= input.dimensions.x) continue; - for (int32_t y = 0; y < input.dim_y; ++y) { + for (int32_t y = 0; y < input.dimensions.y; ++y) { const int32_t sy = y + dwy; - if (sy < 0 || sy >= input.dim_y) + if (sy < 0 || sy >= input.dimensions.y) continue; - shifted[x * input.dim_y + y] = state.facing[sx * input.dim_y + sy]; + shifted[x * input.dimensions.y + y] = + state.facing[sx * input.dimensions.y + sy]; } } state.facing.swap(shifted); @@ -506,7 +564,8 @@ class visual_animation_managerst { 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. + // The scroll is accounted for; the settle window must not block the + // rebased pass. state.suppress_frames = 0; landed_shift = landed; translated = true; @@ -526,12 +585,14 @@ class visual_animation_managerst { 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. + // 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. + // 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) { @@ -539,33 +600,35 @@ class visual_animation_managerst { 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.dim_x) * size_t(input.dim_y), 0); - for (int32_t x = 0; x < input.dim_x; ++x) { - const int32_t sx = x + landed_shift[0]; - if (sx < 0 || sx >= input.dim_x) + 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.dim_y; ++y) { - const int32_t sy = y + landed_shift[1]; - if (sy < 0 || sy >= input.dim_y) + 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.dim_y + y] = - input.previous[layer][sx * input.dim_y + sy]; + 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.dim_x * input.dim_y; + 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. + // 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.dim_x) * size_t(input.dim_y)) + 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))) @@ -575,11 +638,12 @@ class visual_animation_managerst { const int32_t *previous = previous_layers[layer]; const auto shared_delta = static_cast(layer) == viewport_visual_layer::center - ? shared_movement_delta(current, previous, input.dim_x, input.dim_y) - : std::array{}; - for (int32_t x = 0; x < input.dim_x; ++x) { - for (int32_t y = 0; y < input.dim_y; ++y) { - const int32_t target = x * input.dim_y + y; + ? 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; @@ -591,12 +655,12 @@ class visual_animation_managerst { int32_t source = -1; int32_t candidate_count = 0; - if (shared_delta[0] != 0 || shared_delta[1] != 0) { - const int32_t source_x = x + shared_delta[0]; - const int32_t source_y = y + shared_delta[1]; - if (source_x >= 0 && source_x < input.dim_x && source_y >= 0 && - source_y < input.dim_y) { - const int32_t candidate = source_x * input.dim_y + source_y; + 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; @@ -612,10 +676,11 @@ class visual_animation_managerst { continue; const int32_t source_x = x + dx; const int32_t source_y = y + dy; - if (source_x < 0 || source_x >= input.dim_x || source_y < 0 || - source_y >= input.dim_y) + 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.dim_y + source_y; + const int32_t candidate = + source_x * input.dimensions.y + source_y; if (!claimed_sources[candidate] && visual_layer_matches( static_cast(layer), texpos, @@ -631,31 +696,34 @@ class visual_animation_managerst { continue; claimed_sources[source] = 1; - float visual_source_x = float(source / input.dim_y); - float visual_source_y = float(source % input.dim_y); + 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) + 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; + 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, x, y, + 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.dim_x) * size_t(input.dim_y) && + 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.dim_y; - const int32_t target_index = x * input.dim_y + y; + 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]))); @@ -665,7 +733,8 @@ class visual_animation_managerst { } } } - // A source vacates its tile only if no movement this frame claimed it as a target. + // 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)]) @@ -678,15 +747,17 @@ class visual_animation_managerst { 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.dim_y + movement.target_y; + 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.dim_x) * size_t(input.dim_y)) { + // 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; @@ -721,9 +792,9 @@ class visual_animation_managerst { for (const viewport_animationst &state : viewports) { if (state.viewport != viewport) continue; - if (x < 0 || x >= state.dim_x || y < 0 || y >= state.dim_y) + if (x < 0 || x >= state.dimensions.x || y < 0 || y >= state.dimensions.y) break; - const size_t index = size_t(x) * size_t(state.dim_y) + size_t(y); + 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]); @@ -758,21 +829,20 @@ class visual_animation_managerst { 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_x, movement.source_y, - movement_progress(movement.start_time_ms)}; + 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) + 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 || + 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) @@ -781,9 +851,11 @@ class visual_animation_managerst { 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}; + 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 {}; From 742c38876718af58027309aa7bf99fbf8df736e6 Mon Sep 17 00:00:00 2001 From: notliad Date: Thu, 13 Aug 2026 18:24:05 -0300 Subject: [PATCH 5/6] license(smooth-movement): relicense under Zlib --- plugins/smooth-movement/smooth-movement.cpp | 2 +- plugins/smooth-movement/test_visual_animation_manager.cpp | 2 +- plugins/smooth-movement/visual_animation.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp index 09042ee73a..f5290d88d6 100644 --- a/plugins/smooth-movement/smooth-movement.cpp +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Zlib #include #include diff --git a/plugins/smooth-movement/test_visual_animation_manager.cpp b/plugins/smooth-movement/test_visual_animation_manager.cpp index 8bebdee328..6e7b702ca5 100644 --- a/plugins/smooth-movement/test_visual_animation_manager.cpp +++ b/plugins/smooth-movement/test_visual_animation_manager.cpp @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Zlib #include #ifdef NDEBUG diff --git a/plugins/smooth-movement/visual_animation.h b/plugins/smooth-movement/visual_animation.h index fd81b20b26..9577dfcabf 100644 --- a/plugins/smooth-movement/visual_animation.h +++ b/plugins/smooth-movement/visual_animation.h @@ -1,4 +1,4 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: Zlib #ifndef SMOOTH_MOVEMENT_VISUAL_ANIMATION_H #define SMOOTH_MOVEMENT_VISUAL_ANIMATION_H From 6a3aa406b092d1e007749e251a5d35f759fcb4c5 Mon Sep 17 00:00:00 2001 From: notliad Date: Thu, 13 Aug 2026 19:31:19 -0300 Subject: [PATCH 6/6] remove camera feature --- docs/plugins/smooth-movement.rst | 17 - plugins/smooth-movement/smooth-movement.cpp | 386 +------------------- 2 files changed, 1 insertion(+), 402 deletions(-) diff --git a/docs/plugins/smooth-movement.rst b/docs/plugins/smooth-movement.rst index 2cdc579a3e..cdee603b89 100644 --- a/docs/plugins/smooth-movement.rst +++ b/docs/plugins/smooth-movement.rst @@ -18,7 +18,6 @@ Usage enable smooth-movement smooth-movement - smooth-movement camera on|off|reset| smooth-movement flip on|off smooth-movement zlevel on|off @@ -35,28 +34,12 @@ Examples ``smooth-movement flip on`` Horizontally flip creatures so they face their direction of travel. -``smooth-movement camera on`` - Enable smooth camera scrolling and pixel-precise middle-mouse dragging. - -``smooth-movement camera 0.25 -0.25`` - Enable the free camera and offset the view by a quarter tile east and - north of the tile grid. - -``smooth-movement camera reset`` - Return the free camera to the tile grid without disabling it. - ``smooth-movement zlevel on`` Also animate movement on visible z-levels below the main level. Settings -------- -``camera`` (default: off) - Enable or disable smooth camera scrolling and free-camera dragging. The - ``reset`` argument clears the persistent sub-tile offset. Two numeric - arguments set the horizontal and vertical offsets in tiles; each must be - between -0.99 and 0.99. - ``flip`` (default: off) Flip creature sprites horizontally according to their last horizontal movement. Items, vehicles, and designation icons keep their normal diff --git a/plugins/smooth-movement/smooth-movement.cpp b/plugins/smooth-movement/smooth-movement.cpp index f5290d88d6..d1e3edb0ea 100644 --- a/plugins/smooth-movement/smooth-movement.cpp +++ b/plugins/smooth-movement/smooth-movement.cpp @@ -21,7 +21,6 @@ #include "modules/DFSDL.h" #include "df/coord2d.h" -#include "df/enabler.h" #include "df/graphic.h" #include "df/graphic_viewportst.h" #include "df/renderer_2d_base.h" @@ -34,7 +33,6 @@ using namespace DFHack; DFHACK_PLUGIN("smooth-movement"); DFHACK_PLUGIN_IS_ENABLED(is_enabled); -REQUIRE_GLOBAL(enabler); REQUIRE_GLOBAL(gps); REQUIRE_GLOBAL(window_x); REQUIRE_GLOBAL(window_y); @@ -49,7 +47,6 @@ using tile_coveragest = std::set; decltype(&SDL_RenderCopyF) render_copy_f = nullptr; decltype(&SDL_RenderCopyExF) render_copy_ex_f = nullptr; decltype(&SDL_RenderFillRect) render_fill_rect = nullptr; -decltype(&SDL_RenderSetClipRect) render_set_clip_rect = nullptr; decltype(&SDL_GetRenderDrawColor) get_render_draw_color = nullptr; decltype(&SDL_SetRenderDrawColor) set_render_draw_color = nullptr; @@ -79,283 +76,6 @@ bool has_pan_context = false; bool flip_enabled = false; bool zlevel_enabled = false; -// --- free camera -// ------------------------------------------------------------------------------- -// The camera is visually unbound from the tile grid. Two layered offsets: -// rest -- a PERSISTENT sub-tile offset in tiles: the free camera. Set by -// pixel-perfect -// middle-mouse drag panning (the view rests wherever released, -// mid-tile or not) and by the `smooth-movement camera ` -// console command. Survives zoom and z-level changes. Kept in -// [-0.5,0.5] by normalization: whole-tile parts are folded into -// window_x/window_y (a plain UI scroll write -- NEVER the -// viewport dims, which crash DF; the sub-tile strip this leaves -// at one screen edge has no buffer data and stays black). -// transient -- the decaying scroll glide from before, in pixels, layered on -// top. -// Render offset = transient + rest*tile. window_x/window_y remain the game's -// own tile camera. -bool camera_enabled = false; // OFF by default: plain `enable smooth-movement` - // keeps upstream behavior (creature interpolation - // only); `smooth-movement camera on` opts in. -constexpr int32_t camera_max_glide_tiles = 3; // per-jump: farther than this snaps instantly -constexpr double camera_tau_ms = 35.0; // transient catch-up (~95% done after 100ms) -point2dst transient; // decaying glide offset, pixels -point2dst rest; // persistent free-camera offset, tiles -df::coord2d camera_pending = df::coord2d(0, 0); // scroll delta not yet in the buffers -int32_t camera_pending_frames = 0; -df::coord2d self_scroll = df::coord2d(0, 0); // window deltas WE wrote: visual no-ops when landing -bool drag_active = false; -point2dst drag_anchor_view; // visual camera at drag start, tiles -df::coord2d drag_anchor_mouse = df::coord2d(0, 0); // precise mouse at drag start, pixels -bool camera_was_offset = false; // edge-detects offset->0 for one cleanup redraw -df::coord2d camera_previous_window = df::coord2d(0, 0); // window-scroll observation baseline -bool camera_has_prev = false; - -double tile_px(const df::renderer_2d_base *renderer) { - const int32_t zoom = renderer->viewport_zoom_factor; - return double(zoom == 128 ? 32 : std::max(1, zoom * 32 / 128)); -} - -// Match ratio of "buffers shifted by (dwx,dwy)" on the background layer: 0..1, -// or -1 when there is nothing to compare (empty background). -double background_match_ratio(const df::graphic_viewportst *vp, int32_t dwx, int32_t dwy) { - int32_t considered = 0; - int32_t matches = 0; - for (int32_t x = 0; x < vp->dim_x; ++x) { - const int32_t sx = x + dwx; - if (sx < 0 || sx >= vp->dim_x) - continue; - for (int32_t y = 0; y < vp->dim_y; ++y) { - const int32_t sy = y + dwy; - if (sy < 0 || sy >= vp->dim_y) - continue; - const int32_t cur = vp->screentexpos_background[x * vp->dim_y + y]; - if (cur == 0) - continue; - ++considered; - if (vp->screentexpos_background_old[sx * vp->dim_y + sy] == cur) - ++matches; - } - } - if (considered == 0) - return -1.0; - return double(matches) / double(considered); -} - -// Cancel everything except the persistent rest offset (the camera keeps its -// sub-tile position across zoom/z/resize; only the in-flight animation state is -// unfollowable). -void clear_camera_pending() { - camera_pending.x = 0; - camera_pending.y = 0; - camera_pending_frames = 0; - self_scroll.x = 0; - self_scroll.y = 0; -} - -void cancel_camera_transients() { - transient.x = 0.0; - transient.y = 0.0; - clear_camera_pending(); - drag_active = false; -} - -void set_camera_enabled(bool enable) { - if (camera_enabled == enable) - return; - camera_enabled = enable; - cancel_camera_transients(); - rest.x = 0.0; - rest.y = 0.0; - camera_has_prev = false; // fresh observation baseline; no phantom scroll on re-enable - // camera_was_offset stays: the render path issues one cleanup redraw if we - // were mid-offset. -} - -// Fold whole tiles of rest into window_x/window_y so |rest| <= 0.5 (minimal -// edge strip). The visual position is unchanged: the window write is attributed -// via self_scroll when it lands. -void normalize_rest() { - const int32_t kx = int32_t(-std::llround(rest.x)); - const int32_t ky = int32_t(-std::llround(rest.y)); - if (kx != 0 && window_x != nullptr && *window_x + kx >= 0) { - *window_x += kx; - self_scroll.x += kx; - } - if (ky != 0 && window_y != nullptr && *window_y + ky >= 0) { - *window_y += ky; - self_scroll.y += ky; - } -} - -// A scroll of (ax,ay) tiles has landed in the buffers: our own normalization -// writes are visual no-ops (they move into rest); the remainder is a real -// scroll and glides -- unless a drag is driving the position directly, in which -// case it folds into rest wholesale. -void attribute_landed(int32_t ax, int32_t ay, double tile) { - int32_t sx = 0; - if (self_scroll.x != 0 && (self_scroll.x > 0) == (ax > 0) && ax != 0) - sx = (std::abs(self_scroll.x) <= std::abs(ax)) ? self_scroll.x : ax; - int32_t sy = 0; - if (self_scroll.y != 0 && (self_scroll.y > 0) == (ay > 0) && ay != 0) - sy = (std::abs(self_scroll.y) <= std::abs(ay)) ? self_scroll.y : ay; - self_scroll.x -= sx; - self_scroll.y -= sy; - rest.x += sx; - rest.y += sy; - const int32_t gx = ax - sx; - const int32_t gy = ay - sy; - if (drag_active) { - rest.x += gx; - rest.y += gy; - } else { - transient.x += gx * tile; - transient.y += gy * tile; - const double cap = tile * (camera_max_glide_tiles + 0.5); - transient.x = std::clamp(transient.x, -cap, cap); - transient.y = std::clamp(transient.y, -cap, cap); - } -} - -// Per-frame camera bookkeeping: observe window scrolls, attribute them when the -// buffers apply them (glide vs our own normalization writes), drive the drag, -// decay the transient. -void update_camera(df::renderer_2d_base *renderer, const df::graphic_viewportst *vp, - uint32_t delta_ms) { - if (!camera_enabled) - return; - const double tile = tile_px(renderer); - const int32_t wx = window_x ? *window_x : 0; - const int32_t wy = window_y ? *window_y : 0; - if (camera_has_prev && (wx != camera_previous_window.x || wy != camera_previous_window.y)) { - const int32_t dx = wx - camera_previous_window.x; - const int32_t dy = wy - camera_previous_window.y; - if ((std::abs(dx) > camera_max_glide_tiles || std::abs(dy) > camera_max_glide_tiles) && - !drag_active) - cancel_camera_transients(); // teleport-like jump (recenter/minimap): snap - else { - camera_pending.x += dx; - camera_pending.y += dy; - camera_pending_frames = 0; - } - } - camera_previous_window.x = wx; - camera_previous_window.y = wy; - camera_has_prev = true; - - if (camera_pending.x != 0 || camera_pending.y != 0) { - if (std::abs(camera_pending.x) > 6 || std::abs(camera_pending.y) > 6) { - // Scrolling far outran detection: snap (keep rest, drop the animation - // debt). - transient.x = 0.0; - transient.y = 0.0; - clear_camera_pending(); - } else { - // Fast scrolling applies the pending delta PIECEMEAL: the buffers may - // hold +1 of a pending +3 this frame. Testing only the total made - // landings miss, time out, and snap -- the fast-scroll jitter. Instead, - // find the LARGEST applied prefix of the pending scroll and attribute - // just that; the rest keeps pending. Ties between qualifying shifts only - // happen on uniform terrain, where mistiming is invisible. - const int32_t stepx = (camera_pending.x > 0) - (camera_pending.x < 0); - const int32_t stepy = (camera_pending.y > 0) - (camera_pending.y < 0); - int32_t best_ax = 0, best_ay = 0, best_mag = -1; - double best_score = -1.0; - bool no_data = false; - for (int32_t ix = 0; ix <= std::abs(camera_pending.x); ++ix) { - for (int32_t iy = 0; iy <= std::abs(camera_pending.y); ++iy) { - const double score = background_match_ratio(vp, ix * stepx, iy * stepy); - if (score < 0.0) { - no_data = true; - break; - } - const int32_t mag = ix + iy; - if (score >= 0.6 && - (mag > best_mag || (mag == best_mag && score > best_score))) { - best_mag = mag; - best_score = score; - best_ax = ix * stepx; - best_ay = iy * stepy; - } - } - if (no_data) - break; - } - if (no_data) { - // Nothing to compare against (empty background): give up on - // attribution. - clear_camera_pending(); - } else if (best_mag > 0) { - attribute_landed(best_ax, best_ay, tile); - camera_pending.x -= best_ax; - camera_pending.y -= best_ay; - camera_pending_frames = 0; - } else if (best_mag == 0) { - // Content demonstrably hasn't moved yet: keep waiting, no timeout - // pressure. - camera_pending_frames = 0; - } else if (++camera_pending_frames > 4) { - // Neither static nor any prefix recognizable (heavy simultaneous - // change): drop the debt without touching the in-flight glide. - clear_camera_pending(); - } - } - } - - // --- pixel-perfect middle-mouse drag: the view follows the mouse 1:1 and - // rests where released. DF's own drag still moves window in tile steps; rest - // carries the remainder. Positions are tracked against the CONTENT window - // (window minus unlanded jumps) so the buffer lag never causes a visible - // stutter. - const bool mbut = enabler != nullptr && enabler->mouse_mbut; - const double content_wx = double(wx - camera_pending.x); - const double content_wy = double(wy - camera_pending.y); - if (mbut && !drag_active && gps != nullptr) { - drag_active = true; - drag_anchor_view.x = content_wx - rest.x - transient.x / tile; - drag_anchor_view.y = content_wy - rest.y - transient.y / tile; - drag_anchor_mouse.x = gps->precise_mouse_x; - drag_anchor_mouse.y = gps->precise_mouse_y; - transient.x = 0.0; - transient.y = 0.0; - } - if (drag_active) { - if (!mbut) { - drag_active = false; - normalize_rest(); - } else if (gps != nullptr) { - double vx = - drag_anchor_view.x - double(gps->precise_mouse_x - drag_anchor_mouse.x) / tile; - double vy = - drag_anchor_view.y - double(gps->precise_mouse_y - drag_anchor_mouse.y) / tile; - rest.x = content_wx - vx; - rest.y = content_wy - vy; - // If DF's own drag disagrees by more than a tile and a half, rebase on - // its view. - const double lim = 1.5; - if (rest.x < -lim || rest.x > lim || rest.y < -lim || rest.y > lim) { - rest.x = std::clamp(rest.x, -lim, lim); - rest.y = std::clamp(rest.y, -lim, lim); - drag_anchor_view.x = - content_wx - rest.x + double(gps->precise_mouse_x - drag_anchor_mouse.x) / tile; - drag_anchor_view.y = - content_wy - rest.y + double(gps->precise_mouse_y - drag_anchor_mouse.y) / tile; - } - } - } - - if (transient.x != 0.0 || transient.y != 0.0) { - const double k = std::exp(-double(delta_ms) / camera_tau_ms); - transient.x *= k; - transient.y *= k; - if (std::abs(transient.x) < 0.5 && std::abs(transient.y) < 0.5) { - transient.x = 0.0; - transient.y = 0.0; - } - } -} - constexpr uint32_t fire_bits = 0x70000000U; void update_visual_context(const df::renderer_2d_base *renderer, const df::graphic_viewportst *vp) { @@ -376,7 +96,6 @@ void update_visual_context(const df::renderer_2d_base *renderer, const df::graph if (changed) { ++visual_context_revision; previous_coverage.clear(); - cancel_camera_transients(); } previous_viewport = vp; previous_visual_context = context; @@ -1057,24 +776,10 @@ void render_interpolated_world(df::renderer_2d_base *renderer) { if (!viewport_readable(vp) || renderer->sdl_renderer == nullptr) return; - update_camera(renderer, vp, animation_manager.get_frame_delta_ms()); - const double cam_tile = tile_px(renderer); - const int32_t glide_x = int32_t(std::lround(transient.x + rest.x * cam_tile)); - const int32_t glide_y = int32_t(std::lround(transient.y + rest.y * cam_tile)); - const bool glide = glide_x != 0 || glide_y != 0; - if (!glide && camera_was_offset) { - // The camera just re-joined the grid: one engine redraw replaces the last - // shifted frame. - camera_was_offset = false; - if (gps != nullptr) - ++gps->force_full_display_count; - } - if (glide) - camera_was_offset = true; const bool animation_redraw = zlevel_enabled ? animation_manager.requires_full_redraw() : animation_manager.has_active_movement(vp) || !previous_coverage.empty(); - if (!glide && !animation_redraw && (!flip_enabled || !has_mirrored_viewport_facing(viewports))) + if (!animation_redraw && (!flip_enabled || !has_mirrored_viewport_facing(viewports))) return; std::vector viewport_renders = collect_viewport_renders(renderer, viewports); @@ -1084,45 +789,6 @@ void render_interpolated_world(df::renderer_2d_base *renderer) { const int32_t zoom = renderer->viewport_zoom_factor; const int32_t tile_size = zoom == 128 ? 32 : std::max(1, zoom * 32 / 128); - if (glide) { - // Camera mid-glide: repaint the WHOLE map rect at the shifted origin so the - // world (and the creature proxies, which read origin at draw time) renders - // between tiles. The engine already drew this frame at the snapped - // position; everything here overdraws it, clipped to the map rect so - // shifted tiles never spill over the UI. The uncovered strip on the - // trailing edge stays black until the glide lands. - const SDL_Rect map_rect = {tile_pixel(vp->clipx[0], renderer->origin_x, zoom), - tile_pixel(vp->clipy[0], renderer->origin_y, zoom), - tile_pixel(vp->clipx[1] + 1, renderer->origin_x, zoom) - - tile_pixel(vp->clipx[0], renderer->origin_x, zoom), - tile_pixel(vp->clipy[1] + 1, renderer->origin_y, zoom) - - tile_pixel(vp->clipy[0], renderer->origin_y, zoom)}; - render_set_clip_rect(sdl_renderer, &map_rect); - 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); - render_fill_rect(sdl_renderer, &map_rect); - set_render_draw_color(sdl_renderer, old_r, old_g, old_b, old_a); - - const int32_t saved_origin_x = renderer->origin_x; - const int32_t saved_origin_y = renderer->origin_y; - renderer->origin_x += glide_x; - renderer->origin_y += glide_y; - for (int32_t x = vp->clipx[0]; x <= vp->clipx[1]; ++x) { - for (int32_t y = vp->clipy[0]; y <= vp->clipy[1]; ++y) - redraw_world_tile(renderer, viewport_renders, coverage, x, y); - } - draw_viewport_interpolation_stages(renderer, viewport_renders, coverage); - renderer->origin_x = saved_origin_x; - renderer->origin_y = saved_origin_y; - render_set_clip_rect(sdl_renderer, nullptr); - - // Everything was repainted; per-tile coverage bookkeeping restarts after - // the glide. - previous_coverage.clear(); - return; - } - 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; @@ -1163,7 +829,6 @@ void clear_sdl_bindings() { render_copy_f = nullptr; render_copy_ex_f = nullptr; render_fill_rect = nullptr; - render_set_clip_rect = nullptr; get_render_draw_color = nullptr; set_render_draw_color = nullptr; } @@ -1181,7 +846,6 @@ bool load_sdl(color_ostream &out) { bind(SDL_RenderCopyF, render_copy_f); bind(SDL_RenderCopyExF, render_copy_ex_f); bind(SDL_RenderFillRect, render_fill_rect); - bind(SDL_RenderSetClipRect, render_set_clip_rect); bind(SDL_GetRenderDrawColor, get_render_draw_color); bind(SDL_SetRenderDrawColor, set_render_draw_color); #undef bind @@ -1198,12 +862,6 @@ void reset_state() { previous_pan.x = 0; previous_pan.y = 0; has_pan_context = false; - cancel_camera_transients(); - rest.x = 0.0; - rest.y = 0.0; - camera_enabled = false; - camera_has_prev = false; - camera_was_offset = false; flip_enabled = false; zlevel_enabled = false; } @@ -1211,52 +869,10 @@ void reset_state() { command_result status_command(color_ostream &out, std::vector ¶meters) { if (parameters.empty()) { out.print("smooth-movement: {}\n", is_enabled ? "enabled" : "disabled"); - out.print("free camera: {}, offset {:.3f} {:.3f} (tiles east/south of the " - "grid)\n", - camera_enabled ? "on" : "off", -rest.x, -rest.y); 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] == "camera") { - if (parameters.size() == 1) { - out.print("free camera: {}, offset {:.3f} {:.3f}\n", camera_enabled ? "on" : "off", - -rest.x, -rest.y); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "on") { - set_camera_enabled(true); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "off") { - set_camera_enabled(false); - return CR_OK; - } - if (parameters.size() == 2 && parameters[1] == "reset") { - rest.x = 0.0; - rest.y = 0.0; - return CR_OK; - } - if (parameters.size() == 3) { - try { - const double fx = std::stod(parameters[1]); - const double fy = std::stod(parameters[2]); - if (fx < -0.99 || fx > 0.99 || fy < -0.99 || fy > 0.99) { - out.printerr("offsets must be within -0.99..0.99 tiles\n"); - return CR_FAILURE; - } - // User-facing: positive = view sits east/south of the grid position. - set_camera_enabled(true); - rest.x = -fx; - rest.y = -fy; - normalize_rest(); - return CR_OK; - } catch (...) { - return CR_WRONG_USAGE; - } - } - return CR_WRONG_USAGE; - } if (parameters[0] == "flip") { if (parameters.size() == 1) { out.print("sprite flipping: {}\n", flip_enabled ? "on" : "off");