Initial commit: Theriapolis baseline at port/godot branch point
Captures the pre-Godot-port state of the codebase. This is the rollback anchor for the Godot port (M0 of theriapolis-rpg-implementation-plan-godot-port.md). All Phase 0 through Phase 6.5 work is included; Phase 7 is in flight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
using Microsoft.Xna.Framework;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using Theriapolis.Game.CodexUI.Core;
|
||||
|
||||
namespace Theriapolis.Game.CodexUI.Widgets;
|
||||
|
||||
/// <summary>
|
||||
/// Vertical scroll container. Measures its child at unbounded height to
|
||||
/// learn the full content size, then arranges the child shifted by
|
||||
/// <see cref="ScrollOffset"/>. Mouse-wheel input changes the offset; bounds
|
||||
/// hit-testing for hover/click still uses screen-space, so widgets that
|
||||
/// scroll out of view simply stop receiving cursor events.
|
||||
///
|
||||
/// Drawing is uncapped — child widgets draw in their offset positions, so
|
||||
/// content above/below the visible band can spill into adjacent regions.
|
||||
/// The screen's stepper and nav bar are painted with opaque backgrounds
|
||||
/// to mask this overflow, which is cheaper than scissor clipping and avoids
|
||||
/// the SpriteBatch end/restart dance.
|
||||
/// </summary>
|
||||
public sealed class ScrollPanel : CodexWidget
|
||||
{
|
||||
public CodexWidget? Child { get; set; }
|
||||
public int ScrollOffset { get; private set; }
|
||||
private int _contentHeight;
|
||||
private readonly CodexAtlas _atlas;
|
||||
|
||||
/// <summary>
|
||||
/// Fires whenever the wheel changes the scroll offset. The wizard
|
||||
/// uses this to persist offset across <c>InvalidateLayout</c>: the
|
||||
/// rebuilt tree creates a new <see cref="ScrollPanel"/>, but the
|
||||
/// stored value gets re-applied via <see cref="SetInitialScroll"/>.
|
||||
/// </summary>
|
||||
public System.Action<int>? OnScrollChanged { get; set; }
|
||||
|
||||
public ScrollPanel(CodexAtlas atlas, CodexWidget? child = null)
|
||||
{
|
||||
_atlas = atlas;
|
||||
Child = child;
|
||||
if (child is not null) child.Parent = this;
|
||||
}
|
||||
|
||||
/// <summary>Restore a saved offset before the first measure-arrange pass runs.</summary>
|
||||
public void SetInitialScroll(int offset) => ScrollOffset = offset;
|
||||
|
||||
protected override Point MeasureCore(Point available)
|
||||
{
|
||||
if (Child is null)
|
||||
{
|
||||
_contentHeight = 0;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
var s = Child.Measure(new Point(System.Math.Max(0, available.X - 8), int.MaxValue / 2));
|
||||
_contentHeight = s.Y;
|
||||
return new Point(available.X, available.Y);
|
||||
}
|
||||
|
||||
protected override void ArrangeCore(Rectangle bounds)
|
||||
{
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(bounds.X, bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, bounds.Width - 8), _contentHeight));
|
||||
}
|
||||
|
||||
public override void Update(GameTime gt, CodexInput input)
|
||||
{
|
||||
if (Bounds.Contains(input.MousePosition) && input.ScrollDelta != 0)
|
||||
{
|
||||
ScrollOffset -= input.ScrollDelta / 2;
|
||||
ClampScroll();
|
||||
Child?.Arrange(new Rectangle(Bounds.X, Bounds.Y - ScrollOffset,
|
||||
System.Math.Max(0, Bounds.Width - 8), _contentHeight));
|
||||
OnScrollChanged?.Invoke(ScrollOffset);
|
||||
}
|
||||
|
||||
// Nest a clip into the visible viewport so children scrolled out
|
||||
// of view don't register hover/click. Intersect with any outer
|
||||
// clip the parent already set so we never widen its scope.
|
||||
var prevClip = input.GetMouseClip();
|
||||
var newClip = prevClip is Rectangle p ? Rectangle.Intersect(p, Bounds) : Bounds;
|
||||
input.SetMouseClip(newClip);
|
||||
Child?.Update(gt, input);
|
||||
if (prevClip is Rectangle r) input.SetMouseClip(r);
|
||||
else input.ClearMouseClip();
|
||||
}
|
||||
|
||||
public override void Draw(SpriteBatch sb, GameTime gt)
|
||||
{
|
||||
Child?.Draw(sb, gt);
|
||||
|
||||
// Scrollbar thumb on the right edge — only when content overflows.
|
||||
if (_contentHeight > Bounds.Height)
|
||||
{
|
||||
int trackX = Bounds.Right - 4;
|
||||
int trackH = Bounds.Height;
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, Bounds.Y, 2, trackH),
|
||||
new Color(CodexColors.Rule.R, CodexColors.Rule.G, CodexColors.Rule.B, (byte)80));
|
||||
|
||||
int thumbH = System.Math.Max(24, (int)((float)trackH * trackH / _contentHeight));
|
||||
float t = (float)ScrollOffset / System.Math.Max(1, _contentHeight - trackH);
|
||||
int thumbY = Bounds.Y + (int)((trackH - thumbH) * t);
|
||||
sb.Draw(_atlas.Pixel, new Rectangle(trackX, thumbY, 2, thumbH), CodexColors.Gild);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClampScroll()
|
||||
{
|
||||
int max = System.Math.Max(0, _contentHeight - Bounds.Height);
|
||||
if (ScrollOffset < 0) ScrollOffset = 0;
|
||||
if (ScrollOffset > max) ScrollOffset = max;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user