The CSequence frame-advance core (gaps G3/G4/G5/G6/G8/G9/G19), ACE-verified skeleton + retail constants + the P0-pinned leftover carry: - update_internal (0x005255d0): frame_number += framerate*dt; overshoot clamps to the RAW high/low frame with leftover-time computation + animDone; the pose/physics/hook triple fires for EVERY crossed integer frame (ascending fwd / descending rev, strict > < boundaries, NO epsilon, NO safety cap); AnimDone is a LIST-STRUCTURE gate (head != first_cyclic) queuing the global AnimDoneHook; iterative loop carries the leftover into the next node (P0 pin — a lag spike fast-forwards through multiple queued nodes in one tick). - advance_to_next_animation (0x005252b0): four pose ops per transition (subtract1 outgoing @ current frame + residual physics; step — fwd wraps to first_cyclic, REVERSE wraps to the LIST TAIL, asymmetric by design; reseed from the incoming direction-aware boundary; combine incoming + physics). Pose ops run in BOTH directions — ACE's framerate-sign gates are ACE-isms, decomp is unconditional modulo the degenerate-framerate guard. - update (0x00525b80): non-empty -> update_internal + apricot; empty + frame -> accumulated-physics free motion (G8). - execute_hooks (0x00524830): direction filter (Both or match) QUEUING into the IAnimHookQueue host seam (stands in for CPhysicsObj.anim_hooks + the AnimDoneHook singleton; drain placement moves in R6). Null part frame guarded (documented safe divergence vs retail's latent deref). - FrameOps.Combine/Subtract1: the AFrame pose composition (verified math from the pre-R1 port, now against DatReaderWriter Frame). 10 conformance tests: single-tick goldens, exact-integer boundary sit (the old #61 flash class), cyclic wrap without AnimDone, link->cycle fast-forward proving hook order (link hooks -> ANIMDONE -> cycle hooks) AND the carry, reverse descending hooks with the OOB high+1 start, reverse tail-wrap, zero-framerate physics-only, empty-list free motion, pos-frame root motion into the caller Frame, apricot trim via update. Full suite 3346 green. R1 remaining: P5 (adapter cutover — AnimationSequencer rehosted on the core, legacy epsilon/stale-head/safety-cap DELETED) + P6 (root-motion wiring + API narrowing). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
511 lines
20 KiB
C#
511 lines
20 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using DatReaderWriter.DBObjs;
|
||
using DatReaderWriter.Types;
|
||
|
||
namespace AcDream.Core.Physics.Motion;
|
||
|
||
/// <summary>
|
||
/// R1-P4 host seam standing in for retail's <c>CPhysicsObj.anim_hooks</c>
|
||
/// SmartArray + the global <c>AnimDoneHook</c> singleton
|
||
/// (<c>add_anim_hook</c> 0x00514c20; <c>process_hooks</c> 0x00511550 drains
|
||
/// once per physics tick — the drain point stays with the host until R6
|
||
/// places it per retail's UpdateObjectInternal order).
|
||
/// </summary>
|
||
public interface IAnimHookQueue
|
||
{
|
||
/// <summary>Queue a matched AnimFrame hook (already direction-filtered
|
||
/// by <c>execute_hooks</c>).</summary>
|
||
void AddAnimHook(DatReaderWriter.Types.AnimationHook hook);
|
||
|
||
/// <summary>Queue the global animation-done hook — retail's
|
||
/// <c>AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone →
|
||
/// CPartArray::AnimationDone(1)</c> chain (R2 consumes it as
|
||
/// MotionDone).</summary>
|
||
void AddAnimDoneHook();
|
||
}
|
||
|
||
/// <summary>
|
||
/// R1-P2 — verbatim port of retail's <c>CSequence</c> container + list
|
||
/// surgery (Phase R plan stage R1; oracle
|
||
/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17,
|
||
/// §20, §24). The per-tick advance (`update`/`update_internal`/
|
||
/// `apply_physics`/hook dispatch) lands in R1-P3/P4.
|
||
///
|
||
/// Structure: a doubly-linked animation-node list with two cursors —
|
||
/// <see cref="CurrAnim"/> (the node currently playing) and
|
||
/// <see cref="FirstCyclic"/> (where the looping tail begins; everything
|
||
/// before it is one-shot "link" animation). Retail invariant (G10):
|
||
/// <c>append_animation</c> slides <c>first_cyclic</c> to the JUST-APPENDED
|
||
/// node on EVERY call — the cyclic tail is always exactly the last node
|
||
/// appended so far.
|
||
///
|
||
/// Physics accumulators (<see cref="Velocity"/>/<see cref="Omega"/>) live on
|
||
/// the SEQUENCE, not per node (G16); retail replaces them via
|
||
/// <c>set_velocity/set_omega</c> and algebraically blends via
|
||
/// <c>combine_physics/subtract_physics</c> (the R2 fast path's mechanism).
|
||
///
|
||
/// Divergence register (rows added with this commit):
|
||
/// <list type="bullet">
|
||
/// <item><c>frame_number</c> is x87 <c>long double</c> in retail
|
||
/// (acclient.h:30747); C# <c>double</c> is the closest available (G15).</item>
|
||
/// <item>The intrusive DLList is a managed <see cref="LinkedList{T}"/>;
|
||
/// node identity semantics preserved via <see cref="LinkedListNode{T}"/>
|
||
/// references.</item>
|
||
/// </list>
|
||
/// </summary>
|
||
public sealed class CSequence
|
||
{
|
||
private readonly LinkedList<AnimSequenceNode> _animList = new(); // anim_list (DLList)
|
||
private LinkedListNode<AnimSequenceNode>? _firstCyclic; // first_cyclic
|
||
private LinkedListNode<AnimSequenceNode>? _currAnim; // curr_anim
|
||
private readonly IAnimationLoader _loader;
|
||
|
||
/// <summary>Fractional frame position within <see cref="CurrAnim"/>.
|
||
/// Retail x87 long double → double (register row, G15).</summary>
|
||
public double FrameNumber;
|
||
|
||
/// <summary>Sequence root-motion velocity accumulator (body-local).</summary>
|
||
public Vector3 Velocity;
|
||
|
||
/// <summary>Sequence angular-velocity accumulator.</summary>
|
||
public Vector3 Omega;
|
||
|
||
/// <summary>Static pose used when no animation node is active
|
||
/// (<c>placement_frame</c>, §16).</summary>
|
||
public AnimationFrame? PlacementFrame { get; private set; }
|
||
|
||
public uint PlacementFrameId { get; private set; }
|
||
|
||
public CSequence(IAnimationLoader loader) => _loader = loader;
|
||
|
||
// ── inspection surface (adapter + tests) ────────────────────────────
|
||
|
||
public AnimSequenceNode? CurrAnim => _currAnim?.Value;
|
||
public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value;
|
||
public int Count => _animList.Count;
|
||
|
||
/// <summary><c>has_anims</c> (0x00524bd0).</summary>
|
||
public bool HasAnims() => _animList.Count > 0;
|
||
|
||
/// <summary>TEST SEAM: reposition curr_anim by list index (retail state
|
||
/// reached via update_internal, which lands in P4).</summary>
|
||
public void SetCurrAnimForTest(int index)
|
||
{
|
||
var n = _animList.First;
|
||
for (int i = 0; i < index && n != null; i++) n = n.Next;
|
||
_currAnim = n;
|
||
}
|
||
|
||
// ── append_animation (0x00525510, §24) ──────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Append a MotionData anim entry. A node whose dat animation fails to
|
||
/// resolve is discarded. <c>first_cyclic</c> slides to the appended
|
||
/// node on EVERY call; <c>curr_anim</c> seeds to the head (with
|
||
/// <c>frame_number = get_starting_frame()</c>) only when it was null.
|
||
/// </summary>
|
||
public void AppendAnimation(AnimData animData)
|
||
{
|
||
var node = new AnimSequenceNode(animData, _loader);
|
||
if (!node.HasAnim)
|
||
return; // retail deletes the node — discard
|
||
|
||
_animList.AddLast(node);
|
||
_firstCyclic = _animList.Last;
|
||
|
||
if (_currAnim is null)
|
||
{
|
||
_currAnim = _animList.First;
|
||
FrameNumber = _currAnim!.Value.GetStartingFrame();
|
||
}
|
||
}
|
||
|
||
// ── clear family (§3-§5) ────────────────────────────────────────────
|
||
|
||
/// <summary><c>clear</c> (0x005255b0): full wipe INCLUDING the placement
|
||
/// fields — the raw body resets them (the "2-instruction clear" note in
|
||
/// the gap map was wrong; raw decomp is authority).</summary>
|
||
public void Clear()
|
||
{
|
||
ClearAnimations();
|
||
ClearPhysics();
|
||
PlacementFrame = null;
|
||
PlacementFrameId = 0;
|
||
}
|
||
|
||
/// <summary><c>clear_animations</c> (0x00524dc0): delete every node,
|
||
/// null both cursors, zero <c>frame_number</c>.</summary>
|
||
public void ClearAnimations()
|
||
{
|
||
_animList.Clear();
|
||
_firstCyclic = null;
|
||
_currAnim = null;
|
||
FrameNumber = 0.0;
|
||
}
|
||
|
||
/// <summary><c>clear_physics</c> (0x00524d50).</summary>
|
||
public void ClearPhysics()
|
||
{
|
||
Velocity = Vector3.Zero;
|
||
Omega = Vector3.Zero;
|
||
}
|
||
|
||
// ── remove family (§6-§8) ───────────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// <c>remove_cyclic_anims</c> (0x00524e40): delete <c>first_cyclic</c>
|
||
/// → tail. A removed <c>curr_anim</c> snaps BACK to the previous node
|
||
/// with <c>frame_number = prev.get_ending_frame()</c> (or 0.0 when the
|
||
/// list emptied). Afterwards <c>first_cyclic</c> = new tail (or null).
|
||
/// </summary>
|
||
public void RemoveCyclicAnims()
|
||
{
|
||
var node = _firstCyclic;
|
||
while (node is not null)
|
||
{
|
||
var next = node.Next;
|
||
if (ReferenceEquals(_currAnim, node))
|
||
{
|
||
var prev = node.Previous;
|
||
_currAnim = prev;
|
||
FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame();
|
||
}
|
||
_animList.Remove(node);
|
||
node = next;
|
||
}
|
||
_firstCyclic = _animList.Last;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>remove_link_animations(count)</c> (0x00524be0): delete up to
|
||
/// <paramref name="count"/> predecessors of <c>first_cyclic</c>. A
|
||
/// removed <c>curr_anim</c> snaps FORWARD to <c>first_cyclic</c> with
|
||
/// <c>frame_number = get_starting_frame()</c>.
|
||
/// </summary>
|
||
public void RemoveLinkAnimations(int count)
|
||
{
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var prev = _firstCyclic?.Previous;
|
||
if (prev is null)
|
||
break;
|
||
|
||
if (ReferenceEquals(_currAnim, prev))
|
||
{
|
||
_currAnim = _firstCyclic;
|
||
if (_firstCyclic is not null)
|
||
FrameNumber = _firstCyclic.Value.GetStartingFrame();
|
||
}
|
||
_animList.Remove(prev);
|
||
}
|
||
}
|
||
|
||
/// <summary><c>remove_all_link_animations</c> (0x00524ca0): loop until
|
||
/// <c>first_cyclic</c> has no predecessor.</summary>
|
||
public void RemoveAllLinkAnimations()
|
||
{
|
||
while (_firstCyclic?.Previous is not null)
|
||
RemoveLinkAnimations(1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>apricot</c> (0x00524b40; the PDB-verified retail name): trim
|
||
/// consumed nodes from the head, bounded by BOTH <c>curr_anim</c>
|
||
/// (stop — still live) and <c>first_cyclic</c> (defensive bound —
|
||
/// never delete into the cyclic tail). Called after every update (§22).
|
||
/// </summary>
|
||
public void Apricot()
|
||
{
|
||
var head = _animList.First;
|
||
if (head is null || ReferenceEquals(head, _currAnim))
|
||
return;
|
||
|
||
while (!ReferenceEquals(head, _firstCyclic))
|
||
{
|
||
_animList.Remove(head!);
|
||
head = _animList.First;
|
||
if (head is null || ReferenceEquals(head, _currAnim))
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── physics accumulators (§10-§13) ──────────────────────────────────
|
||
|
||
public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880
|
||
public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0
|
||
public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0
|
||
public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900
|
||
|
||
// ── multiply_cyclic_animation_fr (0x00524940, §14) ──────────────────
|
||
|
||
/// <summary>
|
||
/// Scale the framerate of every node from <c>first_cyclic</c> to the
|
||
/// tail. Framerates ONLY (G13) — retail rescales the sequence
|
||
/// velocity/omega separately via <c>change_cycle_speed</c>'s
|
||
/// <c>subtract_motion</c>/<c>combine_motion</c> composite (R2).
|
||
/// </summary>
|
||
public void MultiplyCyclicAnimationFramerate(float factor)
|
||
{
|
||
for (var n = _firstCyclic; n is not null; n = n.Next)
|
||
n.Value.MultiplyFramerate(factor);
|
||
}
|
||
|
||
// ── placement + accessors (§15-§17) ─────────────────────────────────
|
||
|
||
/// <summary><c>set_placement_frame</c> (0x005249b0).</summary>
|
||
public void SetPlacementFrame(AnimationFrame? frame, uint id)
|
||
{
|
||
PlacementFrame = frame;
|
||
PlacementFrameId = id;
|
||
}
|
||
|
||
/// <summary><c>get_curr_animframe</c> (0x00524970): the floored current
|
||
/// part frame, or the placement frame when no node is active.</summary>
|
||
public AnimationFrame? GetCurrAnimframe()
|
||
{
|
||
if (_currAnim is null)
|
||
return PlacementFrame;
|
||
return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber));
|
||
}
|
||
|
||
/// <summary><c>get_curr_frame_number</c> (0x005249d0).</summary>
|
||
public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber);
|
||
|
||
// ── apply_physics (0x00524ab0, §19) ─────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// Accumulated-physics root motion: advance <paramref name="frame"/> by
|
||
/// <see cref="Velocity"/>/<see cref="Omega"/> over a signed quantum —
|
||
/// MAGNITUDE from <paramref name="quantum"/>, SIGN from
|
||
/// <paramref name="signSource"/> (retail copysign semantics; call sites
|
||
/// pass 1/framerate as magnitude and the signed elapsed time as sign).
|
||
/// </summary>
|
||
public void ApplyPhysics(Frame frame, double quantum, double signSource)
|
||
{
|
||
double signed = Math.Abs(quantum);
|
||
if (signSource < 0.0)
|
||
signed = -signed;
|
||
|
||
float sq = (float)signed;
|
||
frame.Origin += Velocity * sq;
|
||
FrameOps.Rotate(frame, Omega * sq);
|
||
}
|
||
|
||
// ── R1-P4: the frame-advance core ───────────────────────────────────
|
||
|
||
/// <summary>Host hook queue (<c>hook_obj</c>); null = hooks dropped
|
||
/// (objects without a physics host).</summary>
|
||
public IAnimHookQueue? HookObj;
|
||
|
||
/// <summary>
|
||
/// <c>CSequence::update</c> (0x00525b80, §22): non-empty list →
|
||
/// <see cref="UpdateInternal"/> then <see cref="Apricot"/>; empty list
|
||
/// with a Frame → accumulated-physics free motion.
|
||
/// </summary>
|
||
public void Update(double timeElapsed, Frame? frame)
|
||
{
|
||
if (_animList.Count > 0 && _currAnim is not null)
|
||
{
|
||
UpdateInternal(timeElapsed, frame);
|
||
Apricot();
|
||
}
|
||
else if (frame is not null)
|
||
{
|
||
ApplyPhysics(frame, timeElapsed, timeElapsed);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>CSequence::update_internal</c> (0x005255d0, §21 — ACE-verified
|
||
/// skeleton, P0-pins.md): advance <see cref="FrameNumber"/> by
|
||
/// framerate·dt; on overshoot clamp to the RAW high/low frame, compute
|
||
/// the leftover time, and mark animDone; fire the pose/physics/hook
|
||
/// triple for EVERY crossed integer frame (ascending forward,
|
||
/// descending reverse); queue the global AnimDoneHook when the list
|
||
/// HEAD is no longer the cyclic tail (G5's structural gate); advance to
|
||
/// the next node and LOOP with the carried leftover (P0 pin).
|
||
/// Iterative (retail while-true), NO safety cap (G4), NO boundary
|
||
/// epsilon (G1/G3).
|
||
/// </summary>
|
||
public void UpdateInternal(double timeElapsed, Frame? frame)
|
||
{
|
||
while (true)
|
||
{
|
||
if (_currAnim is null)
|
||
return;
|
||
|
||
var currAnim = _currAnim.Value;
|
||
double framerate = currAnim.Framerate;
|
||
double frametime = framerate * timeElapsed;
|
||
int lastFrame = (int)Math.Floor(FrameNumber);
|
||
|
||
FrameNumber += frametime;
|
||
double frameTimeElapsed = 0.0;
|
||
bool animDone = false;
|
||
|
||
if (frametime > 0.0)
|
||
{
|
||
if (currAnim.HighFrame < Math.Floor(FrameNumber))
|
||
{
|
||
double frameOffset = FrameNumber - currAnim.HighFrame - 1.0;
|
||
if (frameOffset < 0.0)
|
||
frameOffset = 0.0;
|
||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||
frameTimeElapsed = frameOffset / framerate;
|
||
FrameNumber = currAnim.HighFrame;
|
||
animDone = true;
|
||
}
|
||
while (Math.Floor(FrameNumber) > lastFrame)
|
||
{
|
||
if (frame is not null)
|
||
{
|
||
var pos = currAnim.GetPosFrame(lastFrame);
|
||
if (pos is not null)
|
||
FrameOps.Combine(frame, pos);
|
||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
|
||
}
|
||
ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1);
|
||
lastFrame++;
|
||
}
|
||
}
|
||
else if (frametime < 0.0)
|
||
{
|
||
if (currAnim.LowFrame > Math.Floor(FrameNumber))
|
||
{
|
||
double frameOffset = FrameNumber - currAnim.LowFrame;
|
||
if (frameOffset > 0.0)
|
||
frameOffset = 0.0;
|
||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||
frameTimeElapsed = frameOffset / framerate;
|
||
FrameNumber = currAnim.LowFrame;
|
||
animDone = true;
|
||
}
|
||
while (Math.Floor(FrameNumber) < lastFrame)
|
||
{
|
||
if (frame is not null)
|
||
{
|
||
var pos = currAnim.GetPosFrame(lastFrame);
|
||
if (pos is not null)
|
||
FrameOps.Subtract1(frame, pos);
|
||
if (Math.Abs(framerate) > FrameOps.FEpsilon)
|
||
ApplyPhysics(frame, 1.0 / framerate, timeElapsed);
|
||
}
|
||
ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1);
|
||
lastFrame--;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon)
|
||
ApplyPhysics(frame, timeElapsed, timeElapsed);
|
||
return;
|
||
}
|
||
|
||
if (!animDone)
|
||
return;
|
||
|
||
// AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) —
|
||
// fire only when the consumed head is not already the cyclic
|
||
// tail (retail 0x00525943-0x00525968).
|
||
if (HookObj is not null
|
||
&& _animList.First is not null
|
||
&& !ReferenceEquals(_animList.First, _firstCyclic))
|
||
{
|
||
HookObj.AddAnimDoneHook();
|
||
}
|
||
|
||
AdvanceToNextAnimation(timeElapsed, frame);
|
||
timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>CSequence::advance_to_next_animation</c> (0x005252b0, §23): four
|
||
/// pose operations per transition — un-apply the outgoing node's pose
|
||
/// (subtract1 + residual physics), step (forward wraps to
|
||
/// <c>first_cyclic</c>; REVERSE wraps to the LIST TAIL — asymmetric by
|
||
/// design), reseed <see cref="FrameNumber"/> from the incoming node's
|
||
/// direction-aware boundary, apply the incoming pose (combine +
|
||
/// physics). Pose ops run in BOTH directions (ACE's framerate-sign
|
||
/// gates are ACE-isms; the decomp is unconditional apart from the
|
||
/// degenerate-framerate guard).
|
||
/// </summary>
|
||
public void AdvanceToNextAnimation(double timeElapsed, Frame? frame)
|
||
{
|
||
if (_currAnim is null)
|
||
return;
|
||
|
||
var outgoing = _currAnim.Value;
|
||
|
||
// (a) un-apply the outgoing node's pose at the CURRENT FrameNumber.
|
||
if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon)
|
||
{
|
||
var pos = outgoing.GetPosFrame((int)FrameNumber);
|
||
if (pos is not null)
|
||
FrameOps.Subtract1(frame, pos);
|
||
ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed);
|
||
}
|
||
|
||
// (b) step; (c) reseed FrameNumber from the incoming boundary.
|
||
if (timeElapsed >= 0.0)
|
||
{
|
||
_currAnim = _currAnim.Next ?? _firstCyclic;
|
||
if (_currAnim is null)
|
||
return;
|
||
FrameNumber = _currAnim.Value.GetStartingFrame();
|
||
}
|
||
else
|
||
{
|
||
_currAnim = _currAnim.Previous ?? _animList.Last;
|
||
if (_currAnim is null)
|
||
return;
|
||
FrameNumber = _currAnim.Value.GetEndingFrame();
|
||
}
|
||
|
||
// (d) apply the incoming node's pose at the new FrameNumber.
|
||
var incoming = _currAnim.Value;
|
||
if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon)
|
||
{
|
||
var pos = incoming.GetPosFrame((int)FrameNumber);
|
||
if (pos is not null)
|
||
FrameOps.Combine(frame, pos);
|
||
ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>CSequence::execute_hooks</c> (0x00524830, §18): QUEUE the part
|
||
/// frame's hooks whose direction is Both (0) or matches the playback
|
||
/// direction (+1 forward / −1 backward) into the host. Null part frame
|
||
/// guarded (retail has a latent null-deref here — documented safe
|
||
/// divergence, G18).
|
||
/// </summary>
|
||
private void ExecuteHooks(AnimationFrame? partFrame, int direction)
|
||
{
|
||
if (partFrame is null || HookObj is null)
|
||
return;
|
||
|
||
foreach (var hook in partFrame.Hooks)
|
||
{
|
||
if (hook is null)
|
||
continue;
|
||
int dir = (int)hook.Direction;
|
||
if (dir == 0 || dir == direction)
|
||
HookObj.AddAnimHook(hook);
|
||
}
|
||
}
|
||
|
||
// ── internal cursors for P4 (update_internal operates on nodes) ─────
|
||
|
||
internal LinkedListNode<AnimSequenceNode>? CurrAnimNode
|
||
{
|
||
get => _currAnim;
|
||
set => _currAnim = value;
|
||
}
|
||
|
||
internal LinkedListNode<AnimSequenceNode>? FirstCyclicNode => _firstCyclic;
|
||
internal LinkedList<AnimSequenceNode> AnimList => _animList;
|
||
}
|