From 4d3db17a897005c194e27a549d023be0e36f2ace Mon Sep 17 00:00:00 2001 From: Christopher Wiebe Date: Sat, 2 May 2026 20:36:19 -0700 Subject: [PATCH] =?UTF-8?q?M6.2:=20Step=20V=20Abilities=20=E2=80=94=20drag?= =?UTF-8?q?-drop=20assignment=20+=20roll/auto-assign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per GODOT_PORTING_GUIDE.md §7, the highest-risk piece in the wizard. Three reusable widgets + the orchestrating step. Scenes/Widgets/AbilityToken.cs: Draggable Control with Value + Origin metadata. _GetDragData returns a Dictionary payload {kind, value, from, ability, idx} per guide §7.1. MouseFilter = Pass so clicks propagate to the parent slot for the click-to-return affordance (later removed; see commit body). Drag preview is a dimmed duplicate. Scenes/Widgets/AbilitySlot.cs: PanelContainer drop target per guide §7.2. Accepts any ability_value payload via _CanDropData / _DropData and emits Dropped(payload). Each slot owns one ability id (STR/DEX/CON/INT/ WIS/CHA). Scenes/Widgets/AbilityPool.cs: HBoxContainer drop target per guide §7.3. Accepts only slot→pool drops (returning an assigned value to the pool); pool→pool drops are no-ops. Scenes/Steps/StepStats.cs: Direct port of StepStats in steps.jsx per guide §7.4. Standard array (default) and roll-4d6-drop-lowest method tabs; Reroll button visible in roll mode; Auto Assign sorts the remaining pool descending and places the largest values into empty slots ordered by class.PrimaryAbility. Three drag-drop cases (pool→slot, slot→slot swap, slot→pool) all delegate to a single Patch call, then the entire token tree rebuilds from the new draft state on the Changed signal — handlers don't reparent anything manually. Issues hit during development and resolved before commit: - Initial click-to-return on slot pre-empted drag-from-slot every time (the GuiInput fired on mouse-down, before Godot detected the drag). Removed click-to-return — drag is the canonical interaction; that matches the React prototype anyway. - Token MouseFilter = Stop blocked clicks from reaching the slot layer; switched to Pass which still works as a drag source. - Refresh() teardown + rebuild reset the parent ScrollContainer's scroll to 0 every drop. CallDeferred / SetDeferred / CreateTimer all raced because layout settles over multiple frames; the fix that worked was capturing scroll position pre-rebuild and restoring in _Process the next frame. Wizard.cs: StepTypes[5] = typeof(StepStats); the Abilities step is now reachable. (StepTypes[1..4, 6..7] still null — coming in M6.3+.) Verified: all three drag scenarios + click handling + auto-assign + method switch + reroll work; scroll position holds across drops. Closes M6.2. Next per guide §12: M6.3 — popover system (TraitChip + shared PopoverLayer) before adding more easy card-grid steps. Co-Authored-By: Claude Opus 4.7 --- Theriapolis.Godot/Scenes/Steps/StepStats.cs | 371 ++++++++++++++++++ .../Scenes/Widgets/AbilityPool.cs | 37 ++ .../Scenes/Widgets/AbilitySlot.cs | 40 ++ .../Scenes/Widgets/AbilityToken.cs | 71 ++++ Theriapolis.Godot/Scenes/Wizard.cs | 2 +- 5 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 Theriapolis.Godot/Scenes/Steps/StepStats.cs create mode 100644 Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs create mode 100644 Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs create mode 100644 Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs diff --git a/Theriapolis.Godot/Scenes/Steps/StepStats.cs b/Theriapolis.Godot/Scenes/Steps/StepStats.cs new file mode 100644 index 0000000..73ee87b --- /dev/null +++ b/Theriapolis.Godot/Scenes/Steps/StepStats.cs @@ -0,0 +1,371 @@ +using Godot; +using System.Collections.Generic; +using System.Linq; +using Theriapolis.GodotHost.Scenes.Widgets; +using Theriapolis.GodotHost.UI; + +namespace Theriapolis.GodotHost.Scenes.Steps; + +/// +/// Step V — Abilities. Direct port of StepStats in +/// src/steps.jsx per GODOT_PORTING_GUIDE.md §7.4: pool of 6 +/// dice, 6 ability slots, drag-and-drop assignment with pool→slot, +/// slot→slot (swap), and slot→pool semantics. +/// +/// Two methods supported (CharacterDraft.StatMethod): "array" uses the +/// standard array [15, 14, 13, 12, 10, 8]; "roll" generates each pool +/// value as a 4d6-drop-lowest roll. A Reroll button is exposed in roll +/// mode. Auto-assign sorts the remaining pool descending and places the +/// largest values into the empty slots ordered by class primary +/// abilities first. +/// +/// The step rebuilds the entire token tree on each Changed signal, so +/// drag handlers just compute the new state and call Patch — no manual +/// reparenting. +/// +public partial class StepStats : VBoxContainer, IStep +{ + private static readonly string[] Abilities = + { "STR", "DEX", "CON", "INT", "WIS", "CHA" }; + + private static readonly System.Random Rng = new(); + + private CharacterDraft _draft = null!; + private AbilityPool _pool = null!; + private readonly Dictionary _slots = new(); + private Button _arrayBtn = null!; + private Button _rollBtn = null!; + private Button _rerollBtn = null!; + private Button _autoBtn = null!; + private ScrollContainer? _scroll; + private int _savedScroll = -1; + private bool _scrollPending; + + public void Bind(CharacterDraft draft) + { + _draft = draft; + _draft.Changed += Refresh; + Build(); + } + + public string? Validate() + { + int n = _draft?.StatAssign.Count ?? 0; + return n == 6 ? null : $"Assign all six abilities ({n}/6)."; + } + + private void Build() + { + AddThemeConstantOverride("separation", 18); + + var intro = new VBoxContainer(); + intro.AddThemeConstantOverride("separation", 6); + AddChild(intro); + intro.AddChild(new Label { Text = "FOLIO V · ABILITIES" }); + intro.AddChild(new Label { Text = "Assign your Ability Scores" }); + intro.AddChild(new Label + { + Text = "Pick a method, then drag a value from the pool into one of the six " + + "ability slots. Drag between slots to swap, or drag back to the " + + "pool to unassign. Auto Assign places the remaining pool into the " + + "empty slots, prioritising your calling's primary abilities.", + AutowrapMode = TextServer.AutowrapMode.WordSmart, + }); + + // Method + action toolbar + var toolbar = new HBoxContainer(); + toolbar.AddThemeConstantOverride("separation", 12); + AddChild(toolbar); + + _arrayBtn = new Button { Text = "Standard Array" }; + _arrayBtn.Pressed += () => SwitchMethod("array"); + toolbar.AddChild(_arrayBtn); + + _rollBtn = new Button { Text = "Roll 4d6 (drop lowest)" }; + _rollBtn.Pressed += () => SwitchMethod("roll"); + toolbar.AddChild(_rollBtn); + + var spacer = new Control { SizeFlagsHorizontal = SizeFlags.ExpandFill }; + toolbar.AddChild(spacer); + + _rerollBtn = new Button { Text = "Reroll" }; + _rerollBtn.Pressed += Reroll; + toolbar.AddChild(_rerollBtn); + + _autoBtn = new Button { Text = "Auto Assign" }; + _autoBtn.Pressed += AutoAssign; + toolbar.AddChild(_autoBtn); + + var poolBox = new VBoxContainer(); + poolBox.AddThemeConstantOverride("separation", 6); + AddChild(poolBox); + poolBox.AddChild(new Label { Text = "AVAILABLE" }); + _pool = new AbilityPool(); + _pool.Dropped += OnPoolDropped; + poolBox.AddChild(_pool); + + var slotsBox = new VBoxContainer(); + slotsBox.AddThemeConstantOverride("separation", 8); + AddChild(slotsBox); + slotsBox.AddChild(new Label { Text = "ABILITIES" }); + + foreach (var ab in Abilities) + { + string captured = ab; // capture for the closure below + var row = new HBoxContainer(); + row.AddThemeConstantOverride("separation", 12); + slotsBox.AddChild(row); + + row.AddChild(new Label + { + Text = captured, + CustomMinimumSize = new Vector2(64, 0), + VerticalAlignment = VerticalAlignment.Center, + }); + + var slot = new AbilitySlot { Ability = captured }; + slot.Dropped += (Godot.Collections.Dictionary payload) + => HandleSlotDrop(payload, captured); + _slots[captured] = slot; + row.AddChild(slot); + } + + Refresh(); + } + + // ────────────────────────────────────────────────────────────────────── + // Refresh — rebuild token children from CharacterDraft state. + + private void Refresh() + { + if (_pool is null) return; + + // Tearing down + rebuilding child token trees triggers a layout + // pass that resets the parent ScrollContainer's vertical scroll. + // Snapshot the scroll position and restore it after the new layout + // settles. The restore goes through a method (not SetDeferred on + // the property) so it runs reliably after layout finishes — the + // method is queued via CallDeferred at the end of Refresh. + _scroll = FindAncestorScroll(); + _savedScroll = _scroll?.ScrollVertical ?? -1; + + // Toolbar reflects current method and assignment state. + bool isRoll = _draft.StatMethod == "roll"; + _rerollBtn.Visible = isRoll; + _arrayBtn.Disabled = !isRoll; // greyed when already on Array + _rollBtn.Disabled = isRoll; // greyed when already on Roll + _autoBtn.Disabled = _draft.StatPool.Count == 0; + + // Pool: one token per StatPool entry. Free() (synchronous) so old + // children disappear *before* AddChild runs and the layout pass + // doesn't see a transient doubled-content state. + foreach (var c in _pool.GetChildren()) c.Free(); + for (int i = 0; i < _draft.StatPool.Count; i++) + { + var token = new AbilityToken + { + Value = _draft.StatPool[i], + Origin = "pool", + OriginPoolIdx = i, + }; + _pool.AddChild(token); + } + + // Slots: token if assigned, otherwise dash placeholder. + foreach (var (ab, slot) in _slots) + { + foreach (var c in slot.GetChildren()) c.Free(); + if (_draft.StatAssign.ContainsKey(ab)) + { + int v = (int)_draft.StatAssign[ab]; + var token = new AbilityToken + { + Value = v, + Origin = "slot", + OriginAbility = ab, + }; + slot.AddChild(token); + } + else + { + slot.AddChild(new Label + { + Text = "—", + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }); + } + } + + // Restore scroll on the next _Process frame — after layout has fully + // converged. CallDeferred / SetDeferred / CreateTimer all proved + // racy because the ScrollContainer re-clamps scroll on later layout + // passes triggered by tree mutations elsewhere. + if (_savedScroll >= 0 && _scroll is not null) _scrollPending = true; + } + + public override void _Process(double delta) + { + if (_scrollPending && _scroll is not null && IsInstanceValid(_scroll)) + { + _scroll.ScrollVertical = _savedScroll; + } + _scrollPending = false; + } + + private ScrollContainer? FindAncestorScroll() + { + Node? n = GetParent(); + while (n is not null) + { + if (n is ScrollContainer sc) return sc; + n = n.GetParent(); + } + return null; + } + + // ────────────────────────────────────────────────────────────────────── + // Drop handlers — compute new state and Patch. + // Mirrors handleDrop / dropToPool in steps.jsx exactly. + + private void HandleSlotDrop(Godot.Collections.Dictionary payload, string destAbility) + { + var pool = _draft.StatPool.Duplicate(); + var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); + + string from = payload["from"].AsString(); + int value = payload["value"].AsInt32(); + + if (from == "pool") + { + // pool → slot + int idx = payload["idx"].AsInt32(); + if (assign.ContainsKey(destAbility)) + { + // Bump the existing slot value back to the pool. + pool.Add((int)assign[destAbility]); + } + if (idx >= 0 && idx < pool.Count) pool.RemoveAt(idx); + assign[destAbility] = value; + } + else if (from == "slot") + { + string srcAbility = payload["ability"].AsString(); + if (srcAbility == destAbility) return; + // Swap if dest is filled, otherwise move. + if (assign.ContainsKey(destAbility)) + { + var dstV = assign[destAbility]; + assign[srcAbility] = dstV; + } + else + { + assign.Remove(srcAbility); + } + assign[destAbility] = value; + } + + _draft.Patch(new Godot.Collections.Dictionary + { + { "stat_pool", pool }, + { "stat_assign", assign }, + }); + } + + // ────────────────────────────────────────────────────────────────────── + // Method switch + roll + auto-assign + + private void SwitchMethod(string method) + { + var pool = method == "roll" ? RollSixSet() : ArrayPool(); + _draft.Patch(new Godot.Collections.Dictionary + { + { "stat_method", method }, + { "stat_pool", pool }, + { "stat_assign", new Godot.Collections.Dictionary() }, + }); + } + + private void Reroll() + { + if (_draft.StatMethod != "roll") return; + _draft.Patch(new Godot.Collections.Dictionary + { + { "stat_pool", RollSixSet() }, + { "stat_assign", new Godot.Collections.Dictionary() }, + }); + } + + private void AutoAssign() + { + if (_draft.StatPool.Count == 0) return; + + // Empty slots, ordered by class primary abilities first. + var cls = CodexContent.Class(_draft.ClassId); + var primary = cls?.PrimaryAbility ?? System.Array.Empty(); + var emptyAbilities = Abilities + .Where(a => !_draft.StatAssign.ContainsKey(a)) + .OrderBy(a => + { + int idx = System.Array.IndexOf(primary, a); + return idx < 0 ? 99 : idx; + }) + .ToList(); + + // Pool sorted descending — biggest values go to highest priority. + var sortedPool = _draft.StatPool.OrderByDescending(v => v).ToList(); + var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); + var newPool = new Godot.Collections.Array(); + + for (int i = 0; i < sortedPool.Count; i++) + { + if (i < emptyAbilities.Count) assign[emptyAbilities[i]] = sortedPool[i]; + else newPool.Add(sortedPool[i]); + } + + _draft.Patch(new Godot.Collections.Dictionary + { + { "stat_pool", newPool }, + { "stat_assign", assign }, + }); + } + + private static Godot.Collections.Array ArrayPool() => + new() { 15, 14, 13, 12, 10, 8 }; + + private static Godot.Collections.Array RollSixSet() + { + var pool = new Godot.Collections.Array(); + for (int i = 0; i < 6; i++) pool.Add(RollFourDropLowest()); + return pool; + } + + private static int RollFourDropLowest() + { + // 4d6, drop lowest, sum the rest. Range [3, 18]. + int[] rolls = new int[4]; + for (int i = 0; i < 4; i++) rolls[i] = Rng.Next(1, 7); + System.Array.Sort(rolls); + return rolls[1] + rolls[2] + rolls[3]; + } + + // ────────────────────────────────────────────────────────────────────── + + private void OnPoolDropped(Godot.Collections.Dictionary payload) + { + if (payload["from"].AsString() != "slot") return; + var assign = (Godot.Collections.Dictionary)_draft.StatAssign.Duplicate(); + var pool = _draft.StatPool.Duplicate(); + string ability = payload["ability"].AsString(); + if (!assign.ContainsKey(ability)) return; + int v = (int)assign[ability]; + assign.Remove(ability); + pool.Add(v); + _draft.Patch(new Godot.Collections.Dictionary + { + { "stat_pool", pool }, + { "stat_assign", assign }, + }); + } + +} diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs b/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs new file mode 100644 index 0000000..c7da589 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/AbilityPool.cs @@ -0,0 +1,37 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Container for unassigned AbilityToken children. Per +/// GODOT_PORTING_GUIDE.md §7.3, accepts only slot→pool drops (returning +/// an assigned value to the pool); pool→pool drops are no-ops. The step +/// consumes and mutates CharacterDraft.StatPool / +/// StatAssign in one call so the inevitable refresh re-creates tokens +/// in the right places. +/// +public partial class AbilityPool : HBoxContainer +{ + [Signal] public delegate void DroppedEventHandler(Godot.Collections.Dictionary payload); + + public override void _Ready() + { + AddThemeConstantOverride("separation", 8); + MouseFilter = MouseFilterEnum.Stop; + CustomMinimumSize = new Vector2(0, 64); + } + + public override bool _CanDropData(Vector2 atPosition, Variant data) + { + if (data.VariantType != Variant.Type.Dictionary) return false; + var d = data.AsGodotDictionary(); + if (!d.ContainsKey("kind") || d["kind"].AsString() != "ability_value") return false; + // Only slot→pool drops are meaningful; pool→pool is a no-op. + return d.ContainsKey("from") && d["from"].AsString() == "slot"; + } + + public override void _DropData(Vector2 atPosition, Variant data) + { + EmitSignal(SignalName.Dropped, data); + } +} diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs new file mode 100644 index 0000000..b5e3892 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/AbilitySlot.cs @@ -0,0 +1,40 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Drop target for ability assignment. Per GODOT_PORTING_GUIDE.md §7.2: +/// accepts any drag payload tagged "ability_value" and emits +/// with the payload so the step orchestrates the +/// state change centrally. Visual content (the AbilityToken when filled, +/// or a placeholder dash when empty) is set by the step on every refresh. +/// +/// Each slot owns exactly one ability id (STR/DEX/CON/INT/WIS/CHA). On +/// click of a filled slot, returns the value to the pool (synthesised +/// slot→pool payload) — matches the React prototype's "click to unbind" +/// affordance from steps.jsx. +/// +public partial class AbilitySlot : PanelContainer +{ + [Signal] public delegate void DroppedEventHandler(Godot.Collections.Dictionary payload); + + [Export] public string Ability { get; set; } = "STR"; + + public override void _Ready() + { + CustomMinimumSize = new Vector2(56, 56); + MouseFilter = MouseFilterEnum.Stop; + } + + public override bool _CanDropData(Vector2 atPosition, Variant data) + { + if (data.VariantType != Variant.Type.Dictionary) return false; + var d = data.AsGodotDictionary(); + return d.ContainsKey("kind") && d["kind"].AsString() == "ability_value"; + } + + public override void _DropData(Vector2 atPosition, Variant data) + { + EmitSignal(SignalName.Dropped, data); + } +} diff --git a/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs new file mode 100644 index 0000000..e3bcd63 --- /dev/null +++ b/Theriapolis.Godot/Scenes/Widgets/AbilityToken.cs @@ -0,0 +1,71 @@ +using Godot; + +namespace Theriapolis.GodotHost.Scenes.Widgets; + +/// +/// Draggable ability-score token. Replaces the React prototype's +/// .die + filled .slot in steps.jsx per +/// GODOT_PORTING_GUIDE.md §7.1. Shows the int value as a centered +/// label; _GetDragData returns a Dictionary payload describing +/// where the token came from so the step can mutate CharacterDraft +/// accordingly. +/// +/// Tokens are owned by either an AbilityPool (Origin == "pool", +/// OriginPoolIdx set) or an AbilitySlot (Origin == "slot", +/// OriginAbility set). Dropping a token rebuilds the StepStats UI from +/// the new draft state — token instances are short-lived. +/// +public partial class AbilityToken : PanelContainer +{ + [Export] public int Value { get; set; } + [Export] public string Origin { get; set; } = "pool"; + [Export] public string OriginAbility { get; set; } = ""; + [Export] public int OriginPoolIdx { get; set; } = -1; + + public override void _Ready() + { + CustomMinimumSize = new Vector2(56, 56); + // PASS so clicks propagate up to the parent AbilitySlot's GuiInput + // handler (click-to-return). Drag detection still triggers on the + // deepest non-IGNORE Control under the cursor, so PASS works for + // _GetDragData too. + MouseFilter = MouseFilterEnum.Pass; + + var label = new Label + { + Text = Value.ToString(), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + MouseFilter = MouseFilterEnum.Ignore, + }; + label.AddThemeFontSizeOverride("font_size", 22); + AddChild(label); + } + + public override Variant _GetDragData(Vector2 atPosition) + { + // Drag preview — a dimmed duplicate of the token. + var preview = new Panel { CustomMinimumSize = new Vector2(56, 56) }; + var lbl = new Label + { + Text = Value.ToString(), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + }; + lbl.AddThemeFontSizeOverride("font_size", 22); + lbl.AnchorRight = 1f; + lbl.AnchorBottom = 1f; + preview.AddChild(lbl); + preview.Modulate = new Color(1, 1, 1, 0.85f); + SetDragPreview(preview); + + return new Godot.Collections.Dictionary + { + { "kind", "ability_value" }, + { "value", Value }, + { "from", Origin }, + { "ability", OriginAbility }, + { "idx", OriginPoolIdx }, + }; + } +} diff --git a/Theriapolis.Godot/Scenes/Wizard.cs b/Theriapolis.Godot/Scenes/Wizard.cs index 2e3dfc3..4a273f1 100644 --- a/Theriapolis.Godot/Scenes/Wizard.cs +++ b/Theriapolis.Godot/Scenes/Wizard.cs @@ -41,7 +41,7 @@ public partial class Wizard : Control null, // 2 Calling null, // 3 Subclass null, // 4 History - null, // 5 Abilities + typeof(Steps.StepStats), // 5 Abilities — implemented (M6.2) null, // 6 Skills null, // 7 Sign };