M6.1: Character creation wizard foundation (.tscn + Resource-based draft)

Pivots from M5's code-built UI to the editor-authorable .tscn pattern
recommended in GODOT_PORTING_GUIDE.md, after a session of fighting
Godot idioms with code-only layout. Default theme only; the parchment
Theme lands last per the guide's §12 build order so layout bugs surface
as layout bugs, not theming bugs.

GODOT_PORTING_GUIDE.md:
  Authored by Claude Design as the canonical port reference. Maps the
  React prototype's structure onto Godot 4.6 with concrete code sketches
  and a build-order recommendation. Drove the M6 architecture.

Fonts/:
  Cormorant Garamond (Medium + MediumItalic) and Crimson Pro (Regular +
  Italic + SemiBold) under OFL — the React prototype's serif-display
  and serif-body families. Not yet wired through CodexTheme.Build()
  because theming is deferred; CodexTheme.LoadFontFromFonts already
  picks them up automatically when the Theme pass lands.

Scenes/Wizard.tscn + Wizard.cs:
  Wizard shell per guide §4: codex-header (title + folio counter) +
  Stepper + Page (StepHost + Aside) + NavBar (Back / validation / Next).
  All node lookups via unique-name (%) syntax; layout authored as a
  scene file you can open in the editor. Step lifecycle drives the
  Aside via signal binding. Stepper logic mirrors app.jsx — locked
  iff some EARLIER step is unsatisfied; "type not yet implemented"
  doesn't lock.

Scenes/Aside.tscn + Aside.cs:
  Right-rail summary per guide §10. Single Refresh() rebuild on
  CharacterDraft.Changed; cheap enough not to bother with partial
  updates. Width 320 (was 380 before the layout overflow fix).

Scenes/Steps/IStep.cs + StepClade.cs:
  Per-step Bind(draft) + Validate() contract. StepClade renders the
  3-column clade card grid; click commits via CharacterDraft.Patch
  which triggers the Resource.Changed signal that Aside and Wizard
  both subscribe to.

UI/CharacterDraft.cs:
  Resource (not Node) per guide §2.1. Mirrors app.jsx's `state` shape
  exactly. Patch(dictionary) emits the inherited Resource.Changed
  signal — listeners use `draft.Changed += handler` regardless of
  which field changed. CodexContent provides lazy-loaded immutable
  content tables (Clades, Species, Classes, Subclasses, Backgrounds).

Main.{cs,tscn}: Node → Control
  When Main was a Node, Control children couldn't anchor to a real
  parent rect — they sat at (0,0) at intrinsic min size. With wide
  step content (3-column 200-px-card grid), the Wizard's min size
  pushed the navbar beyond the viewport's right edge, hiding the Next
  button on smaller windowed viewports. Making Main a full-rect-
  anchored Control gives child scenes a proper rect to lay out in.

UI/Widgets/CodexStepper.cs:
  Anchored the inner vbox to fill the button rect. Without this, the
  vbox sat at the button's top-left at intrinsic size and labels
  rendered in the corner — visible as the active-step label being
  off-center from the highlight bar.

Verified at 1152x720 windowed and (separately) at fullscreen:
  - 3-column card grid fits inside Wrap margins + Aside without
    horizontal overflow
  - Stepper labels centered under their highlight bars
  - Next button visible after clade selection; future steps switch
    to "coming soon" placeholder when clicked
  - Aside summary fills in CLADE block on selection

Closes M6.1.  Next per guide §12 build order: M6.2 — StepStats with
drag-drop (highest-risk piece, de-risk before easy steps).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Christopher Wiebe
2026-05-02 19:35:03 -07:00
parent 953bb985ad
commit ee5439285c
18 changed files with 1543 additions and 5 deletions
+726
View File
@@ -0,0 +1,726 @@
# Theriapolis → Godot 4.6 Porting Guide
A companion to `README.md`. This document maps the React/HTML prototype onto a Godot 4.6 project, file by file, with code sketches for the parts that are not obvious. It assumes you've read the existing README and have a working Godot 4.6 install.
> **Scope.** This guide covers the character-creation wizard only — the same surface the prototype covers. It does not address how the resulting character is consumed by the rest of the game; that handoff is described under "The handoff contract" at the end.
---
## 0. The honest summary
The port is **a UI rebuild, not a translation.** What carries over verbatim:
- The four content JSON files in `data/`.
- The `state` shape used as the character record.
- All math (`abilityMod`, modifier summing, validation predicates).
- Step-gating rules and the wizard flow.
- Drag/drop *semantics* (pool→slot, slot→slot, slot→pool).
What gets rebuilt in Godot's idiom:
- All views (HTML/CSS → Control scenes + Theme).
- The hover-popover system (`TraitName`) → a custom `PopupPanel` Control.
- HTML5 drag-and-drop → Godot's `_get_drag_data` / `_can_drop_data` / `_drop_data`.
- The Tweaks panel — almost certainly delete it; bake one theme.
Plan on roughly: 12 days for scaffolding, ~1 day per step scene, 12 days for the popover and drag system, 23 days for theming the parchment look. Plus polish.
---
## 1. Suggested Godot project layout
```
res://
├── project.godot
├── data/ # Copy unchanged from prototype/data/
│ ├── clades.json
│ ├── species.json
│ ├── classes.json
│ └── backgrounds.json
├── fonts/ # Bundle these — do NOT rely on Google Fonts at runtime
│ ├── CormorantGaramond-Medium.ttf
│ ├── CormorantGaramond-MediumItalic.ttf
│ ├── CrimsonPro-Regular.ttf
│ ├── CrimsonPro-Italic.ttf
│ ├── CrimsonPro-SemiBold.ttf
│ └── JetBrainsMono-Regular.ttf
├── theme/
│ ├── parchment.tres # Main Theme resource
│ ├── parchment_styles.gd # Programmatic StyleBox factory (optional)
│ └── paper_grain.png # Baked grain background
├── autoload/
│ ├── content.gd # Loads the four JSON files; replaces data.jsx
│ ├── character.gd # Resource defining the character record
│ └── rules.gd # ABILITIES, SKILL_*, abilityMod(), validate()
├── scenes/
│ ├── wizard.tscn # Root; replaces app.jsx
│ ├── wizard.gd
│ ├── stepper.tscn # 7-tab header; replaces .stepper
│ ├── aside.tscn # Right-rail summary; replaces <Aside />
│ ├── nav_bar.tscn # Bottom Back / Next / Confirm bar
│ ├── steps/
│ │ ├── step_clade.tscn / .gd
│ │ ├── step_species.tscn / .gd
│ │ ├── step_class.tscn / .gd
│ │ ├── step_background.tscn / .gd
│ │ ├── step_stats.tscn / .gd
│ │ ├── step_skills.tscn / .gd
│ │ └── step_review.tscn / .gd
│ └── widgets/
│ ├── codex_card.tscn # Reusable selectable card
│ ├── trait_chip.tscn # Replaces TraitName
│ ├── trait_popover.tscn # The hover hint window
│ ├── ability_token.tscn # Replaces .die / draggable score
│ ├── ability_slot.tscn # Replaces .slot
│ └── stat_strip.tscn # The 6-cell summary row
└── icon.svg
```
### Autoloads (Project Settings → Autoload)
| Name | Path | Singleton | Why |
|-------------|-------------------------|-----------|---------------------------------------|
| `Content` | `res://autoload/content.gd` | Yes | One-shot JSON loader. |
| `Rules` | `res://autoload/rules.gd` | Yes | Static tables and pure functions. |
| (no autoload for the character) | -- | -- | The character is a `Resource` instance owned by `wizard.tscn`. |
---
## 2. The data layer — concrete GDScript
### 2.1 `Character` resource
Mirrors the JS `state` shape exactly. Using `Resource` makes it serializable (`ResourceSaver.save`) and trivially debuggable in the inspector.
```gdscript
# autoload/character.gd
class_name Character
extends Resource
@export var clade_id: StringName = &"canidae"
@export var species_id: StringName = &""
@export var class_id: StringName = &"fangsworn"
@export var background_id: StringName = &"pack_raised"
@export var stat_method: StringName = &"array" # "array" | "roll"
@export var stat_pool: Array[int] = [15, 14, 13, 12, 10, 8]
@export var stat_assign: Dictionary = {} # { "STR": 15, ... }
@export var stat_history: Array[Dictionary] = [] # [{ vals: [int], ts: int }]
@export var chosen_skills: Array[StringName] = []
@export var name_text: String = ""
@export var portrait_style: StringName = &"silhouette"
signal changed # emit on any mutation
func patch(d: Dictionary) -> void:
for k in d:
set(k, d[k])
changed.emit()
```
Wizard steps connect to `changed` and re-render.
### 2.2 `Content` autoload — replaces `loadData()`
```gdscript
# autoload/content.gd
extends Node
var clades: Array = []
var species: Array = []
var classes: Array = []
var backgrounds: Array = []
func _ready() -> void:
clades = _load_json("res://data/clades.json")
species = _load_json("res://data/species.json")
classes = _load_json("res://data/classes.json")
backgrounds = _load_json("res://data/backgrounds.json")
func _load_json(path: String) -> Array:
var f := FileAccess.open(path, FileAccess.READ)
assert(f, "Missing %s" % path)
var parsed = JSON.parse_string(f.get_as_text())
return parsed if parsed is Array else []
func clade(id: StringName): return _find(clades, id)
func species_of(id: StringName): return _find(species, id)
func cls(id: StringName): return _find(classes, id)
func background(id: StringName): return _find(backgrounds, id)
func _find(arr: Array, id: StringName):
for o in arr:
if StringName(o.id) == id: return o
return null
```
### 2.3 `Rules` autoload — replaces `data.jsx` constants and helpers
```gdscript
# autoload/rules.gd
extends Node
const ABILITIES := ["STR", "DEX", "CON", "INT", "WIS", "CHA"]
const STANDARD_ARRAY := [15, 14, 13, 12, 10, 8]
const ABILITY_LABELS := {
"STR": "Strength", "DEX": "Dexterity", "CON": "Constitution",
"INT": "Intellect", "WIS": "Wisdom", "CHA": "Charisma",
}
const SKILL_ABILITY := {
"acrobatics": "DEX", "athletics": "STR",
# ... copy verbatim from src/data.jsx
}
const SKILL_LABEL := { ... } # copy from src/data.jsx
const SKILL_DESC := { ... } # copy from src/data.jsx
const LANGUAGES := { ... } # copy from src/data.jsx
const CLASS_CLADE_REC := {
"fangsworn": ["canidae", "felidae", "ursidae"],
# ... copy verbatim
}
func ability_mod(score: int) -> int:
return int(floor((score - 10) / 2.0))
func signed_str(n: int) -> String:
return "+%d" % n if n >= 0 else str(n)
# Validation per step. Mirrors the JS validate() in app.jsx.
# Returns "" if valid, otherwise an error message.
func validate_step(step: int, ch: Character) -> String:
match step:
0: return "" if ch.clade_id else "Pick a clade."
1: return "" if ch.species_id else "Pick a species."
2: return "" if ch.class_id else "Pick a calling."
3: return "" if ch.background_id else "Pick a background."
4:
var n := ch.stat_assign.size()
return "" if n == 6 else "Assign all six abilities (%d/6)." % n
5:
var c = Content.cls(ch.class_id)
var need: int = c.skills_choose if c else 0
var got := ch.chosen_skills.size()
return "" if got == need else "Pick exactly %d (%d/%d)." % [need, got, need]
6: return "" if ch.name_text.strip_edges() else "Enter a name."
return ""
```
> Tip: copy the full `SKILL_DESC`, `LANGUAGES`, and `TRAIT_READING` dictionaries straight from `src/data.jsx` — the JS object literal is valid as a GDScript dictionary with two find/replace passes (`:` is fine; you mostly need to swap `\n` strings and remove backticks).
---
## 3. Mapping React concepts to Godot
| React / web concept | Godot 4.6 equivalent |
|------------------------------------|--------------------------------------------------------------|
| Component (`StepClade`) | `Control`-derived scene + script |
| Props (`{ state, set }`) | Scene exports + a shared `Character` resource reference |
| `useState` | Plain `var` on the script + `signal` for change notifications |
| `useEffect` on dep change | Connect to `Character.changed`; gate work with diffs |
| `useMemo` | Cache in `_ready()` or recompute in `_process` only on dirty |
| Conditional render | `node.visible = ...` (avoid `queue_free` for steps; reuse) |
| `<style>` + CSS variables | `Theme` resource + `theme_type_variation` |
| Google Font import | `FontFile` resources, referenced in Theme |
| `ReactDOM.createPortal` (popovers) | A `Popup` / `PopupPanel` instanced on the root viewport |
| HTML5 drag-and-drop | `_get_drag_data` / `_can_drop_data` / `_drop_data` |
| `fetch().then(...)` | `FileAccess` + `JSON.parse_string` (synchronous; fine here) |
| `localStorage` | `ConfigFile` or `ResourceSaver.save(user://character.tres)` |
---
## 4. The wizard shell — `wizard.tscn` + `wizard.gd`
Translates `src/app.jsx`. Scene tree:
```
Wizard (Control, full-rect)
├── AppFrame (PanelContainer) # the bordered codex frame
│ ├── Margins (MarginContainer)
│ │ └── Layout (VBoxContainer)
│ │ ├── CodexHeader (HBoxContainer)
│ │ ├── Stepper (HBoxContainer) # 7 step tabs
│ │ └── Page (HBoxContainer)
│ │ ├── PageMain (MarginContainer)
│ │ │ └── StepHost (Control) # one child per step, only one visible
│ │ │ ├── StepClade
│ │ │ ├── StepSpecies
│ │ │ ├── ...
│ │ └── PageAside (PanelContainer)
│ │ └── Aside
│ └── NavBar (HBoxContainer)
└── PopoverLayer (CanvasLayer) # see §6 — popovers live here, above everything
```
Key script logic:
```gdscript
# scenes/wizard.gd
extends Control
@export var character: Character
const STEP_KEYS := ["clade", "species", "class", "background",
"stats", "skills", "review"]
const STEP_NAMES := ["Clade", "Species", "Calling", "History",
"Abilities", "Skills", "Sign"]
var step: int = 0
@onready var step_host: Control = %StepHost
@onready var stepper: HBoxContainer = %Stepper
@onready var nav_back: Button = %NavBack
@onready var nav_next: Button = %NavNext
func _ready() -> void:
if character == null:
character = Character.new()
# Match the prototype's defaults:
var first_canid := Content.species.filter(
func(s): return s.clade_id == "canidae")
if first_canid.size() > 0:
character.species_id = first_canid[0].id
character.changed.connect(_redraw)
_build_stepper()
_show_step(0)
_redraw()
func _show_step(i: int) -> void:
step = i
for child in step_host.get_children():
child.visible = false
var target = step_host.get_node(STEP_KEYS[i].capitalize() + "Step")
target.visible = true
target.refresh() # each step exposes refresh()
_redraw()
func _redraw() -> void:
# Update stepper locked/active/complete states + nav button enable
var first_incomplete := -1
for i in 7:
if Rules.validate_step(i, character) != "":
first_incomplete = i
break
for i in 7:
var tab: Button = stepper.get_child(i)
var locked := i > step and first_incomplete != -1 and first_incomplete < i
var complete := Rules.validate_step(i, character) == "" and i != step
tab.disabled = locked
tab.set_meta(&"active", i == step)
tab.set_meta(&"complete", complete)
tab.queue_redraw() # if you draw the active underline in _draw
nav_back.disabled = step == 0
nav_next.disabled = Rules.validate_step(step, character) != ""
func _on_class_changed() -> void:
# Mirrors the React useEffect that resets skill picks when class changes.
character.chosen_skills.clear()
func _on_clade_changed() -> void:
var sp = Content.species_of(character.species_id)
if sp == null or StringName(sp.clade_id) != character.clade_id:
var match := Content.species.filter(
func(s): return s.clade_id == character.clade_id)
if match.size() > 0:
character.species_id = match[0].id
```
The two `_on_*_changed` helpers replace the prototype's `useEffect` blocks. Wire them by connecting to `Character.changed` and diffing against a remembered last-value, or — simpler — call them explicitly from the click handlers in `step_clade.gd` and `step_class.gd`.
---
## 5. Step scenes — pattern
Every step scene follows the same shape:
```gdscript
# scenes/steps/step_clade.gd
extends VBoxContainer
@onready var grid: GridContainer = %CardGrid
var character: Character
func _ready() -> void:
character = (owner as Wizard).character
func refresh() -> void:
# Clear and rebuild. For tiny lists (<50 items) this is fine; if it
# gets sluggish, switch to pooled cards keyed by id.
for c in grid.get_children(): c.queue_free()
for c in Content.clades:
var card := preload("res://scenes/widgets/codex_card.tscn").instantiate()
card.populate_clade(c, c.id == String(character.clade_id))
card.selected.connect(func(): _pick(c.id))
grid.add_child(card)
func _pick(id: String) -> void:
var first_species := Content.species.filter(
func(s): return s.clade_id == id)
character.patch({
"clade_id": StringName(id),
"species_id": first_species[0].id if first_species else &"",
})
```
`CodexCard` is the unit you'll build twice: a `PanelContainer` styled like a parchment card with selection state. Make it a single reusable scene with a `populate_*` method per kind (clade, species, class, background) — that keeps the data → view mapping explicit.
---
## 6. Hover popovers — the trickiest port
The prototype's `TraitName` (in `trait-hint.jsx`) does three real things:
1. Spawns a popover into the document body so it escapes overflow clipping.
2. Measures and clamps to viewport, flipping above/below as needed.
3. Stays open while the cursor is over either the trigger *or* the popover (with an 80ms grace window).
In Godot, do this:
### 6.1 Architecture
- A single `PopoverLayer` (CanvasLayer) is added once at the top of `wizard.tscn`. It owns one reusable `TraitPopover` scene.
- `TraitChip` (the trigger) is a small `Button` (or `Label` + `Control` with mouse filter `STOP`). On `mouse_entered`, it asks the `PopoverLayer` to show the popover for its trait at its global rect.
### 6.2 Trigger + popover protocol
```gdscript
# scenes/widgets/trait_chip.gd
extends Button
@export var trait_data: Dictionary # {id, name, description, tag?}
@export var detriment: bool = false
func _ready() -> void:
flat = true
mouse_entered.connect(_on_enter)
mouse_exited.connect(_on_exit)
func _on_enter() -> void:
PopoverLayer.show_for(self, trait_data, detriment)
func _on_exit() -> void:
PopoverLayer.schedule_close_from_trigger()
```
```gdscript
# autoload/popover_layer.gd (or attach to the PopoverLayer node in wizard.tscn)
extends CanvasLayer
@onready var popup: PopupPanel = $TraitPopover
var _close_timer: SceneTreeTimer
func show_for(trigger: Control, data: Dictionary, detriment: bool) -> void:
_cancel_close()
popup.populate(data, detriment)
popup.reset_size() # measure
var trig_rect := trigger.get_global_rect()
var popup_size := popup.size
var vp := trigger.get_viewport_rect().size
var pad := 8.0
# default: below
var pos := Vector2(trig_rect.position.x, trig_rect.end.y + 6)
if pos.y + popup_size.y + pad > vp.y \
and trig_rect.position.y - 6 - popup_size.y >= pad:
pos.y = trig_rect.position.y - 6 - popup_size.y # flip above
pos.x = clamp(pos.x, pad, vp.x - popup_size.x - pad)
pos.y = clamp(pos.y, pad, vp.y - popup_size.y - pad)
popup.position = pos
popup.show()
func schedule_close_from_trigger() -> void:
_close_timer = get_tree().create_timer(0.08)
_close_timer.timeout.connect(_close_if_idle)
func _close_if_idle() -> void:
if not popup.get_global_rect().has_point(popup.get_viewport().get_mouse_position()):
popup.hide()
func _cancel_close() -> void:
_close_timer = null # timer fires regardless; _close_if_idle re-checks state
```
The `TraitPopover` scene itself:
```gdscript
# scenes/widgets/trait_popover.gd
extends PopupPanel
@onready var name_lbl: Label = %Name
@onready var tag_lbl: Label = %Tag
@onready var desc_lbl: RichTextLabel = %Desc
@onready var reading_lbl: Label = %Reading
func populate(d: Dictionary, detriment: bool) -> void:
name_lbl.text = d.get("name", "")
tag_lbl.text = d.get("tag", "detriment" if detriment else "")
tag_lbl.visible = not tag_lbl.text.is_empty()
desc_lbl.text = d.get("description", "")
var reading: String = Rules.TRAIT_READING.get(d.get("id", ""), "")
reading_lbl.text = reading
reading_lbl.visible = not reading.is_empty()
mouse_entered.connect(_cancel_close, CONNECT_ONE_SHOT_PERSIST) # re-arm each show
mouse_exited.connect(_request_close, CONNECT_ONE_SHOT_PERSIST)
func _cancel_close() -> void: PopoverLayer._cancel_close()
func _request_close() -> void: PopoverLayer.schedule_close_from_trigger()
```
> **Caveats.** `PopupPanel` is *not* always a perfect fit for hover-driven popovers — by default they auto-close on outside click, which is what you want here. If you need them to overlay other modal popups or coexist with focus, switch to a plain `Control` parented to the CanvasLayer with `mouse_filter = PASS` and your own click-outside detection.
---
## 7. Drag-and-drop on the Abilities step
This is actually *cleaner* in Godot than in HTML. Replace `dataTransfer` JSON strings with structured Dictionaries. The three cases the prototype handles (pool→slot, slot→slot, slot→pool) all fit into the same protocol.
### 7.1 The draggable token (replaces `.die` / filled `.slot`)
```gdscript
# scenes/widgets/ability_token.gd
extends Control
@export var value: int
@export var origin: StringName # &"pool" or &"slot"
@export var origin_ability: StringName # only used when origin == &"slot"
@export var origin_pool_idx: int = -1 # only used when origin == &"pool"
func _get_drag_data(_at_position: Vector2) -> Variant:
var preview := duplicate()
preview.modulate.a = 0.85
set_drag_preview(preview)
return {
"kind": "ability_value",
"value": value,
"from": origin,
"ability": origin_ability,
"idx": origin_pool_idx,
}
```
### 7.2 The slot drop target
```gdscript
# scenes/widgets/ability_slot.gd
extends PanelContainer
@export var ability: StringName # &"STR" etc.
signal dropped(payload: Dictionary)
func _can_drop_data(_p, data) -> bool:
return typeof(data) == TYPE_DICTIONARY and data.get("kind") == "ability_value"
func _drop_data(_p, data) -> void:
dropped.emit(data)
```
### 7.3 The pool drop target — same shape, different signal handler
```gdscript
# scenes/widgets/ability_pool.gd
extends HBoxContainer
signal dropped_to_pool(payload: Dictionary)
func _can_drop_data(_p, data) -> bool:
# Only accept tokens currently in a slot — pool→pool moves are a no-op.
return typeof(data) == TYPE_DICTIONARY \
and data.get("kind") == "ability_value" \
and data.get("from") == &"slot"
func _drop_data(_p, data) -> void:
dropped_to_pool.emit(data)
```
### 7.4 The step orchestrates state changes
This is the direct port of the JS `handleDrop` / `dropToPool` in `steps.jsx`:
```gdscript
# scenes/steps/step_stats.gd (excerpt)
func _on_slot_dropped(dest_ability: StringName, p: Dictionary) -> void:
var pool: Array = character.stat_pool.duplicate()
var assign: Dictionary = character.stat_assign.duplicate()
if p.from == &"pool":
if assign.has(String(dest_ability)):
pool.append(assign[String(dest_ability)]) # bumped value back to pool
pool.remove_at(p.idx)
assign[String(dest_ability)] = p.value
elif p.from == &"slot":
var src := String(p.ability)
var dst := String(dest_ability)
if src == dst: return
var src_v = assign.get(src)
var dst_v = assign.get(dst)
assign[dst] = src_v
if dst_v != null: assign[src] = dst_v
else: assign.erase(src)
character.patch({"stat_pool": pool, "stat_assign": assign})
func _on_pool_dropped(p: Dictionary) -> void:
if p.from != &"slot": return
var assign: Dictionary = character.stat_assign.duplicate()
var v = assign.get(String(p.ability))
if v == null: return
assign.erase(String(p.ability))
var pool: Array = character.stat_pool.duplicate()
pool.append(v)
character.patch({"stat_pool": pool, "stat_assign": assign})
```
Behavior parity with the prototype: clicking a filled slot to return its value to the pool is just a `gui_input` handler on the slot that synthesizes a `slot→pool` payload.
---
## 8. The parchment Theme
The single most important piece of art-direction fidelity. Approach:
### 8.1 Decide where the look lives
The prototype carries three themes (parchment / dark / blood) via CSS variable overrides. **Pick one and ship it.** If you really need runtime theming, build it as a Theme swap, not as parameterized StyleBoxes.
### 8.2 Build a Theme resource
Create `theme/parchment.tres`. Inside the editor, configure these *theme types* (Godot's mechanism for variant-styled controls):
| Theme type | What it styles |
|----------------------|------------------------------------------|
| (default) | Base: ink color, body font. |
| `CodexFrame` | The outer 1px-rule + double-inset border. Use a `StyleBoxFlat` with `border_color = #8a6f48`, `border_width_*`, then a second StyleBox via a child `Panel` for the inner rule. |
| `CodexCard` | The selectable cards. Border + tinted background; selected state via `theme_type_variation = "CodexCardSelected"`. |
| `CodexCardSelected` | Variant: seal-red border + drop shadow. |
| `Stepper` | Bottom-divided header. |
| `Sigil` | Round 56px frame with a gradient. |
| `TraitChip` | Italic chip (uses display font). |
| `BonusPillPos` / `BonusPillNeg` | The seal vs. dim pill. |
Wire each step's nodes to those types via `theme_type_variation`. That replaces the role of CSS class names like `.card.selected`.
### 8.3 Fonts
Drop the TTFs into `res://fonts/`. In Theme:
- Default font → `CrimsonPro-Regular.ttf` at 15px.
- `Label/font` for `theme_type_variation = "Display"``CormorantGaramond-Medium.ttf`.
- `Label/font` for `Mono``JetBrainsMono-Regular.ttf` at 11px, with letter-spacing (Godot 4 supports `theme_override_constants/extra_spacing_glyph` on `Label`).
> Warn: small italic display text (the eyebrow + folio markings) will not be as crisp as it is in the browser. Bump the size up by ~1px or commit to non-italic eyebrows.
### 8.4 The paper grain
Two viable approaches:
1. **Bake it.** Render the prototype's `--paper-grain` once at 1920×1200 to a PNG; use as a `TextureRect` background or a `StyleBoxTexture` on the AppFrame.
2. **Procedural.** A `Sprite2D` with a `NoiseTexture2D` (FastNoiseLite) modulated low-alpha over the parchment color. More flexible; less authored-feeling.
Start with (1). Paper grain is mostly a static asset.
### 8.5 The wax seal mark on selected cards
The prototype draws this with two pseudo-elements (a radial-gradient circle plus an offset glyph). In Godot, the cleanest port is a small `wax_seal.tscn` node placed at the card's top-right corner, visible only when the card is selected. A 64×64 PNG with a slight emboss is fine; if you want it crisper, use `Polygon2D` with a radial vertex-color gradient and a glyph `Label` on top.
---
## 9. The Tweaks panel — drop it
The Tweaks panel is a *prototype-time affordance*. It uses a host-rewrite mechanism (`postMessage(__edit_mode_set_keys, ...)`) that is specific to this prototyping environment and has no equivalent in a shipped game. Delete it. If your game wants player-facing theme/density toggles, build them as part of your settings menu, persisted via `ConfigFile` to `user://settings.cfg`.
---
## 10. Aside (right-rail summary)
`Aside` is the most state-dependent component — it re-derives 5 sections from the character. In Godot, give it a single `refresh()` that rebuilds each section, and connect it to `Character.changed`. Don't try to be clever about partial updates; the panel is small enough that full rebuild is cheap.
The `stat-strip` (six cells with ability / score / mod) is worth extracting as `widgets/stat_strip.tscn` — both Aside and Step 7 use it.
---
## 11. The handoff contract
The prototype's `StepReview` ends at:
```js
onClick={() => alert(`${state.name} steps into Theriapolis. (Confirmed.)`)}
```
In Godot, the Confirm button should:
1. Compose a final `Character` instance.
2. Compute derived stats (final ability scores + mods) — this is *not* stored, since modifiers are inputs from clade/species and can be re-derived; the canonical record is the user's choices.
3. Emit a signal or call into your game's character-creation pipeline.
```gdscript
# scenes/steps/step_review.gd
signal character_confirmed(ch: Character)
func _on_confirm_pressed() -> void:
if not _all_steps_valid(): return
character_confirmed.emit(character)
```
A reasonable consumer somewhere up the tree:
```gdscript
func _on_character_confirmed(ch: Character) -> void:
ResourceSaver.save(ch, "user://character.tres")
SceneManager.change_scene("res://scenes/world/intro.tscn")
```
Final ability score for ability `ab`:
```gdscript
var base: int = ch.stat_assign.get(ab, 0)
var c_mod: int = clade.ability_mods.get(ab, 0)
var s_mod: int = species.ability_mods.get(ab, 0)
var final_score := base + c_mod + s_mod
var final_mod := Rules.ability_mod(final_score)
```
Skill proficiencies (combined): `bg.skill_proficiencies + ch.chosen_skills` — same as the JS.
---
## 12. Suggested build order
Keep yourself honest by porting in this order. Each step adds visible progress, and you can stop at any time and ship a smaller wizard.
1. **Autoloads + Character resource.** No UI. `_ready()` prints the loaded data. Verify counts match the JSON.
2. **Wizard shell + stepper + nav bar.** Plain default theme. Step content is just `Label.text = "Step N"`. Validation gating works.
3. **One step end to end — pick `StepClade`.** Real cards, click selects, Aside reflects the choice. Default theme still.
4. **`StepStats`.** Drag-and-drop is the highest-risk piece — getting it working early de-risks the rest.
5. **The popover system.** Land `TraitChip` + `TraitPopover`. Hook it up everywhere chips appear.
6. **Remaining steps.** Species, Class, Background, Skills, Review. They're all variations on the StepClade pattern.
7. **Aside.** Real summary, all sections.
8. **Theme.** Save until last; theming a half-built UI is wasted work.
9. **Polish.** Drop-cap on intros, sigils per clade, wax-seal selected mark, scroll behavior on long card grids.
---
## 13. Known gotchas to plan for
- **`StringName` vs `String`.** Pick one for IDs and stick to it. The above code uses `StringName` for IDs in `Character` exports and `String` everywhere they cross into JSON-shaped dicts. Mismatches silently fail dictionary lookups.
- **`Dictionary.duplicate()` is shallow.** When patching `stat_assign`, that's fine. If you ever nest mutable dicts inside `Character`, switch to `duplicate(true)`.
- **`Resource` mutation does not auto-trigger UI updates.** Always go through `Character.patch()` so `changed` fires. A direct `character.name_text = "..."` bypasses the signal.
- **Hover popovers and focus.** A `PopupPanel` will steal focus by default on some platforms. Set `popup.unfocusable = true` (Godot 4.6 has `Window.unfocusable`) for hover popovers so they don't disrupt keyboard nav.
- **JSON loader is synchronous.** All four files are tiny; this is fine. Don't overengineer with `WorkerThreadPool` unless you have measurements saying you need to.
- **Card grid responsiveness.** The prototype uses `grid-template-columns: repeat(auto-fill, minmax(240px, 1fr))`. Godot's `GridContainer` requires a fixed column count. Either pick a fixed 3 (good at 1280+) or write a tiny `_on_resized` that picks 2/3/4 columns based on available width.
---
## 14. What this guide does NOT cover
- Localization. The prototype is English-only; the SKILL_DESC and TRAIT_READING strings are content, not just labels.
- Save / load mid-creation. Add via `ResourceSaver.save(character, "user://wip_character.tres")` on each `Character.changed`.
- Audio (page turns, ink scratches, seal stamps) — the prototype is silent; the parchment aesthetic begs for it. Out of scope here.
- Subclasses, equipment, and tool/weapon proficiencies — the prototype's README documents these as known caveats; they remain caveats.
---
*End of guide. Read this alongside `README.md` for the source-side architecture; together they should give you everything needed to reimplement the wizard in Godot 4.6 without re-deriving the design decisions.*
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
+20 -1
View File
@@ -5,7 +5,11 @@ using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost; namespace Theriapolis.GodotHost;
public partial class Main : Node // Control (not Node) so child Control scenes (Wizard, KitchenSink, etc.)
// can anchor to a real rect that fills the viewport. With a plain Node
// parent, anchors are ignored and Controls sit at (0,0) at intrinsic min
// size, which causes wide content to overflow off the right edge.
public partial class Main : Control
{ {
public override void _Ready() public override void _Ready()
{ {
@@ -22,6 +26,7 @@ public partial class Main : Node
ulong? worldMapSeed = null; ulong? worldMapSeed = null;
bool runAssetTest = false; bool runAssetTest = false;
bool runCodexTest = false; bool runCodexTest = false;
bool runWizard = false;
(ulong seed, int tx, int ty)? tacticalArgs = null; (ulong seed, int tx, int ty)? tacticalArgs = null;
for (int i = 0; i < args.Length; i++) for (int i = 0; i < args.Length; i++)
{ {
@@ -30,6 +35,11 @@ public partial class Main : Node
runCodexTest = true; runCodexTest = true;
break; break;
} }
if (args[i] == "--wizard")
{
runWizard = true;
break;
}
if (args[i] == "--smoke-test") if (args[i] == "--smoke-test")
{ {
ulong seed = 12345UL; ulong seed = 12345UL;
@@ -109,6 +119,15 @@ public partial class Main : Node
return; return;
} }
if (runWizard)
{
foreach (Node child in GetChildren())
child.QueueFree();
var packed = ResourceLoader.Load<PackedScene>("res://Scenes/Wizard.tscn");
AddChild(packed.Instantiate());
return;
}
GD.Print("Theriapolis.Godot host ready (M0 hello-world)."); GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
} }
+4 -1
View File
@@ -2,7 +2,10 @@
[ext_resource type="Script" path="res://Main.cs" id="1_main"] [ext_resource type="Script" path="res://Main.cs" id="1_main"]
[node name="Main" type="Node"] [node name="Main" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_main") script = ExtResource("1_main")
[node name="Label" type="Label" parent="."] [node name="Label" type="Label" parent="."]
+58
View File
@@ -0,0 +1,58 @@
using Godot;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// Right-rail summary of the in-progress character. Single
/// <see cref="Refresh"/> rebuilds every section per
/// GODOT_PORTING_GUIDE.md §10 — the panel is small enough that full
/// rebuild is cheap and partial-update logic isn't worth it. Connect
/// the draft via <see cref="SetDraft"/>; the Wizard does this on _Ready.
/// </summary>
public partial class Aside : MarginContainer
{
private CharacterDraft? _draft;
private VBoxContainer _content = null!;
public override void _Ready()
{
AddThemeConstantOverride("margin_left", 18);
AddThemeConstantOverride("margin_right", 18);
AddThemeConstantOverride("margin_top", 18);
AddThemeConstantOverride("margin_bottom", 18);
_content = new VBoxContainer();
_content.AddThemeConstantOverride("separation", 18);
AddChild(_content);
}
public void SetDraft(CharacterDraft draft)
{
_draft = draft;
_draft.Changed += Refresh;
Refresh();
}
private void Refresh()
{
if (_draft is null || _content is null) return;
foreach (var c in _content.GetChildren()) c.QueueFree();
_content.AddChild(new Label { Text = "SUMMARY" });
AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name);
AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name);
AddBlock("Calling", CodexContent.Class(_draft.ClassId)?.Name);
AddBlock("Background", CodexContent.Background(_draft.BackgroundId)?.Name);
AddBlock("Name", string.IsNullOrEmpty(_draft.CharacterName) ? null : _draft.CharacterName);
}
private void AddBlock(string label, string? value)
{
var v = new VBoxContainer();
v.AddThemeConstantOverride("separation", 4);
_content.AddChild(v);
v.AddChild(new Label { Text = label.ToUpperInvariant() });
v.AddChild(new Label { Text = value ?? "—" });
}
}
+9
View File
@@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3 uid="uid://aside6m6v1"]
[ext_resource type="Script" path="res://Scenes/Aside.cs" id="1_aside"]
[node name="Aside" type="MarginContainer"]
unique_name_in_owner = true
custom_minimum_size = Vector2(320, 0)
size_flags_horizontal = 0
script = ExtResource("1_aside")
+17
View File
@@ -0,0 +1,17 @@
namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary>
/// Common contract for character-creation step scenes. Mirrors
/// <c>app.jsx</c>'s per-step <c>validate</c> pattern: each step inspects
/// the shared draft and returns null when its requirements are met, or
/// a short error string otherwise.
///
/// Step classes also extend <see cref="Godot.Control"/> so they can be
/// parented into the wizard's StepHost. Bind() is called once after
/// instantiation; Godot drives the lifecycle as usual.
/// </summary>
public interface IStep
{
void Bind(UI.CharacterDraft draft);
string? Validate();
}
+153
View File
@@ -0,0 +1,153 @@
using Godot;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.UI;
namespace Theriapolis.GodotHost.Scenes.Steps;
/// <summary>
/// Step I — Clade. Direct port of <c>StepClade</c> in
/// <c>src/steps.jsx</c>: intro paragraph, then a card grid with one
/// card per clade. Click selects via <see cref="CharacterDraft.Patch"/>.
///
/// Default theme only at this layer (per GODOT_PORTING_GUIDE.md §12 build
/// order); the parchment look lands in the final theming pass.
/// </summary>
public partial class StepClade : VBoxContainer, IStep
{
private CharacterDraft _draft = null!;
private GridContainer _grid = null!;
public void Bind(CharacterDraft draft)
{
_draft = draft;
_draft.Changed += Refresh;
Build();
}
public string? Validate() => string.IsNullOrEmpty(_draft?.CladeId) ? "Pick a clade." : null;
private void Build()
{
AddThemeConstantOverride("separation", 16);
var intro = new VBoxContainer();
intro.AddThemeConstantOverride("separation", 6);
AddChild(intro);
intro.AddChild(new Label { Text = "FOLIO I · CLADE" });
intro.AddChild(new Label { Text = "Choose a Clade" });
intro.AddChild(new Label
{
Text = "The broad mammalian family of your line. Clade defines the largest "
+ "strokes — predator or prey, communal or solitary, scent-driven or "
+ "sight-driven. Each clade carries inherited traits and limits that "
+ "no character escapes.",
AutowrapMode = TextServer.AutowrapMode.WordSmart,
CustomMinimumSize = new Vector2(0, 0),
});
_grid = new GridContainer
{
Columns = 3,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
};
_grid.AddThemeConstantOverride("h_separation", 16);
_grid.AddThemeConstantOverride("v_separation", 16);
AddChild(_grid);
Refresh();
}
private void Refresh()
{
if (_grid is null) return;
foreach (var c in _grid.GetChildren()) c.QueueFree();
foreach (var clade in CodexContent.Clades)
_grid.AddChild(BuildCard(clade));
}
private Control BuildCard(CladeDef clade)
{
bool selected = _draft.CladeId == clade.Id;
var btn = new Button
{
Text = "",
Flat = false,
ToggleMode = true,
ButtonPressed = selected,
FocusMode = Control.FocusModeEnum.None,
// 200 wide so 3 cards + separators (≈ 632) + page margins +
// Aside fit inside ≥ 1024-px viewports (the smaller-screen
// floor we want to support). Height held at 200 so the inner
// labels render at readable size without a content-driven
// height collapse (Button isn't a Container, so child vbox
// height doesn't bubble up to the button's intrinsic min size).
CustomMinimumSize = new Vector2(200, 200),
ClipText = false,
Alignment = HorizontalAlignment.Left,
};
btn.Pressed += () =>
{
// Default species for the new clade — match React app.jsx:
// when clade changes, species defaults to first species in clade.
string speciesId = "";
foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
{
speciesId = s.Id;
break;
}
_draft.Patch(new Godot.Collections.Dictionary
{
{ "clade_id", clade.Id },
{ "species_id", speciesId },
});
};
// Label content stacked inside the button via an anchored VBoxContainer
// (Button isn't a Container, so we anchor the vbox to fill the button's
// rect and let the children flow within it).
var box = new VBoxContainer
{
MouseFilter = MouseFilterEnum.Ignore,
};
box.AnchorRight = 1f;
box.AnchorBottom = 1f;
box.OffsetLeft = 12;
box.OffsetTop = 12;
box.OffsetRight = -12;
box.OffsetBottom = -12;
box.AddThemeConstantOverride("separation", 6);
btn.AddChild(box);
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
box.AddChild(new Label
{
Text = clade.Kind.ToUpperInvariant(),
MouseFilter = MouseFilterEnum.Ignore,
});
if (clade.AbilityMods.Count > 0)
{
var modsRow = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
modsRow.AddThemeConstantOverride("separation", 8);
box.AddChild(modsRow);
foreach (var (k, v) in clade.AbilityMods)
modsRow.AddChild(new Label
{
Text = $"{k} {(v >= 0 ? "+" : "")}{v}",
MouseFilter = MouseFilterEnum.Ignore,
});
}
if (clade.Traits.Length > 0)
{
box.AddChild(new Label
{
Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
MouseFilter = MouseFilterEnum.Ignore,
});
}
return btn;
}
}
+166
View File
@@ -0,0 +1,166 @@
using Godot;
namespace Theriapolis.GodotHost.Scenes;
/// <summary>
/// Character creation wizard shell. Mirrors <c>src/app.jsx</c> per
/// GODOT_PORTING_GUIDE.md §4: header + stepper + page (step + aside) +
/// nav bar. Owns the <see cref="CharacterDraft"/> resource and dispatches
/// each step's content into the StepHost.
///
/// Default theme only at this layer — per guide §12 (build order),
/// the parchment Theme lands as a final pass once structural correctness
/// is verified. Until then, font/colour issues are clearly font/colour
/// issues, not layout issues.
/// </summary>
public partial class Wizard : Control
{
[Signal] public delegate void BackToTitleEventHandler();
private static readonly string[] StepKeys =
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
private static readonly string[] StepNames =
{ "Clade", "Species", "Calling", "Subclass", "History", "Abilities", "Skills", "Sign" };
public UI.CharacterDraft Character { get; private set; } = null!;
private UI.Widgets.CodexStepper _stepper = null!;
private Control _stepHost = null!;
private Label _folioLabel = null!;
private Label _validation = null!;
private Label _navProgress = null!;
private Button _backBtn = null!;
private Button _nextBtn = null!;
private int _step;
private Steps.IStep? _activeStep;
private static readonly System.Type?[] StepTypes =
{
typeof(Steps.StepClade), // 0 Clade — implemented
null, // 1 Species
null, // 2 Calling
null, // 3 Subclass
null, // 4 History
null, // 5 Abilities
null, // 6 Skills
null, // 7 Sign
};
public override void _Ready()
{
Character = new UI.CharacterDraft();
_stepper = GetNode<UI.Widgets.CodexStepper>("%Stepper");
_stepHost = GetNode<Control>("%StepHost");
_folioLabel = GetNode<Label>("%FolioLabel");
_validation = GetNode<Label>("%ValidationLabel");
_navProgress = GetNode<Label>("%NavProgress");
_backBtn = GetNode<Button>("%BackButton");
_nextBtn = GetNode<Button>("%NextButton");
var aside = GetNode<Aside>("%Aside");
aside.SetDraft(Character);
_stepper.StepClicked += OnStepperClicked;
_backBtn.Pressed += OnBackPressed;
_nextBtn.Pressed += OnNextPressed;
Character.Changed += UpdateChrome;
SwitchToStep(0);
}
// ──────────────────────────────────────────────────────────────────────
// Step lifecycle
private void SwitchToStep(int index)
{
if (index < 0 || index >= StepKeys.Length) return;
_step = index;
foreach (var c in _stepHost.GetChildren()) c.QueueFree();
_activeStep = null;
var t = StepTypes[index];
if (t is null)
{
_stepHost.AddChild(new Label
{
Text = $"{StepNames[index]} step — coming soon.",
});
}
else
{
var instance = (Steps.IStep)System.Activator.CreateInstance(t)!;
_activeStep = instance;
instance.Bind(Character);
_stepHost.AddChild((Control)instance);
}
UpdateChrome();
}
private void OnStepperClicked(int index)
{
if (index <= _step) { SwitchToStep(index); return; }
// Forward jump requires current step satisfied. Unimplemented future
// steps still accept the jump — SwitchToStep will show a placeholder.
if (_activeStep?.Validate() is not null) return;
SwitchToStep(index);
}
private void OnBackPressed()
{
if (_step == 0) { EmitSignal(SignalName.BackToTitle); return; }
SwitchToStep(_step - 1);
}
private void OnNextPressed()
{
if (_step < StepKeys.Length - 1) SwitchToStep(_step + 1);
}
// ──────────────────────────────────────────────────────────────────────
// Chrome (header, stepper, nav-bar) refresh
private void UpdateChrome()
{
_folioLabel.Text = $"Folio {Roman(_step + 1)} of VIII — {StepNames[_step]}";
bool valid = _activeStep?.Validate() is null;
string? err = _activeStep?.Validate();
_validation.Text = err ?? (_step == StepKeys.Length - 1 ? "Ready to sign" : "Folio complete");
_nextBtn.Disabled = !valid;
_nextBtn.Visible = _step < StepKeys.Length - 1;
_backBtn.Text = _step == 0 ? "← Title" : "← Back";
_navProgress.Text = $"{_step + 1} / {StepKeys.Length}";
RebuildStepperStates(valid);
}
private void RebuildStepperStates(bool currentSatisfied)
{
// Mirrors app.jsx's lock semantics: a step is Locked iff some EARLIER
// step is unsatisfied. "Type not yet implemented" doesn't affect the
// lock state — clicking an unimplemented step just shows a placeholder.
var states = new UI.Widgets.CodexStepper.StepState[StepNames.Length];
for (int i = 0; i < StepNames.Length; i++)
{
if (i == _step)
states[i] = UI.Widgets.CodexStepper.StepState.Active;
else if (i < _step)
states[i] = UI.Widgets.CodexStepper.StepState.Complete;
else
states[i] = currentSatisfied
? UI.Widgets.CodexStepper.StepState.Pending
: UI.Widgets.CodexStepper.StepState.Locked;
}
_stepper.SetSteps(StepNames, states);
}
private static string Roman(int n) => n switch
{
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
_ => n.ToString(),
};
}
+87
View File
@@ -0,0 +1,87 @@
[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
[node name="Wizard" type="Control"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1_wizard")
[node name="Wrap" type="MarginContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 36
theme_override_constants/margin_right = 36
theme_override_constants/margin_top = 16
theme_override_constants/margin_bottom = 16
[node name="Layout" type="VBoxContainer" parent="Wrap"]
[node name="Header" type="HBoxContainer" parent="Wrap/Layout"]
theme_override_constants/separation = 24
[node name="TitleCol" type="VBoxContainer" parent="Wrap/Layout/Header"]
size_flags_horizontal = 3
[node name="Title" type="Label" parent="Wrap/Layout/Header/TitleCol"]
text = "THERIAPOLIS · CODEX OF BECOMING"
[node name="FolioLabel" type="Label" parent="Wrap/Layout/Header/TitleCol"]
unique_name_in_owner = true
text = "Folio I of VIII — Clade"
[node name="MetaLabel" type="Label" parent="Wrap/Layout/Header"]
text = "PORT/GODOT · M6"
[node name="Stepper" type="HBoxContainer" parent="Wrap/Layout"]
unique_name_in_owner = true
size_flags_horizontal = 3
script = ExtResource("2_stepper")
[node name="Page" type="HBoxContainer" parent="Wrap/Layout"]
size_flags_vertical = 3
[node name="PageMain" type="MarginContainer" parent="Wrap/Layout/Page"]
size_flags_horizontal = 3
size_flags_vertical = 3
theme_override_constants/margin_left = 12
theme_override_constants/margin_right = 28
theme_override_constants/margin_top = 16
theme_override_constants/margin_bottom = 16
[node name="Scroll" type="ScrollContainer" parent="Wrap/Layout/Page/PageMain"]
size_flags_horizontal = 3
size_flags_vertical = 3
horizontal_scroll_mode = 0
[node name="StepHost" type="VBoxContainer" parent="Wrap/Layout/Page/PageMain/Scroll"]
unique_name_in_owner = true
size_flags_horizontal = 3
[node name="Aside" parent="Wrap/Layout/Page" instance=ExtResource("3_aside")]
[node name="NavBar" type="HBoxContainer" parent="Wrap/Layout"]
theme_override_constants/separation = 24
[node name="BackButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "← Back"
[node name="Spacer" type="Control" parent="Wrap/Layout/NavBar"]
size_flags_horizontal = 3
[node name="ValidationLabel" type="Label" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = ""
[node name="NavProgress" type="Label" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "1 / 8"
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
unique_name_in_owner = true
text = "Next "
+106
View File
@@ -0,0 +1,106 @@
using Godot;
using System.Collections.Generic;
using Theriapolis.Core.Data;
using Theriapolis.GodotHost.Platform;
namespace Theriapolis.GodotHost.UI;
/// <summary>
/// In-progress character record. Resource (not Node) per
/// GODOT_PORTING_GUIDE.md §2.1: serializable via ResourceSaver.save,
/// inspectable in the editor, doesn't need tree presence.
///
/// Mirrors <c>app.jsx</c>'s <c>state</c> shape — clade/species/class/etc.,
/// stat assignment, chosen skills, name. Mutate via <see cref="Patch"/> so
/// every change emits <see cref="Changed"/>; the Aside and Wizard listen
/// and re-render on that single signal.
/// </summary>
[GlobalClass]
public partial class CharacterDraft : Resource
{
// NB: Resource defines its own `Changed` signal (emitted via EmitChanged()).
// We piggyback on it instead of declaring our own to avoid a name clash —
// listeners do `draft.Changed += ...` exactly the same way either way.
[Export] public string CladeId { get; set; } = "";
[Export] public string SpeciesId { get; set; } = "";
[Export] public string ClassId { get; set; } = "";
[Export] public string SubclassId { get; set; } = "";
[Export] public string BackgroundId { get; set; } = "";
/// <summary>"array" or "roll" — assignment method for ability scores.</summary>
[Export] public string StatMethod { get; set; } = "array";
[Export] public Godot.Collections.Array<int> StatPool { get; set; }
= new() { 15, 14, 13, 12, 10, 8 };
[Export] public Godot.Collections.Dictionary StatAssign { get; set; } = new();
[Export] public Godot.Collections.Array<string> ChosenSkills { get; set; } = new();
[Export] public string CharacterName { get; set; } = "";
/// <summary>
/// Mutate one or more fields, then emit Changed once. Mirrors
/// the JS prototype's <c>set(patch)</c> from app.jsx.
/// </summary>
public void Patch(Godot.Collections.Dictionary patch)
{
foreach (var key in patch.Keys)
{
string k = (string)key;
switch (k)
{
case "clade_id": CladeId = (string)patch[key]; break;
case "species_id": SpeciesId = (string)patch[key]; break;
case "class_id": ClassId = (string)patch[key]; break;
case "subclass_id": SubclassId = (string)patch[key]; break;
case "background_id": BackgroundId = (string)patch[key]; break;
case "stat_method": StatMethod = (string)patch[key]; break;
case "stat_pool": StatPool = (Godot.Collections.Array<int>)patch[key]; break;
case "stat_assign": StatAssign = (Godot.Collections.Dictionary)patch[key]; break;
case "chosen_skills": ChosenSkills = (Godot.Collections.Array<string>)patch[key]; break;
case "character_name":CharacterName = (string)patch[key]; break;
default:
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
break;
}
}
EmitChanged();
}
}
/// <summary>
/// Lazy-loaded immutable content tables. Replaces the React prototype's
/// data.jsx + autoload Content per GODOT_PORTING_GUIDE.md §2.2 — same
/// purpose, idiomatic C#.
/// </summary>
public static class CodexContent
{
private static CladeDef[]? _clades;
private static SpeciesDef[]? _species;
private static ClassDef[]? _classes;
private static SubclassDef[]? _subclasses;
private static BackgroundDef[]? _backgrounds;
public static CladeDef[] Clades => _clades ??= Load(l => l.LoadClades());
public static SpeciesDef[] Species => _species ??= Load(l => l.LoadSpecies(Clades));
public static ClassDef[] Classes => _classes ??= Load(l => l.LoadClasses());
public static SubclassDef[] Subclasses => _subclasses ??= Load(l => l.LoadSubclasses(Classes));
public static BackgroundDef[] Backgrounds => _backgrounds ??= Load(l => l.LoadBackgrounds());
public static CladeDef? Clade(string id) => System.Array.Find(Clades, c => c.Id == id);
public static SpeciesDef? SpeciesById(string id) => System.Array.Find(Species, s => s.Id == id);
public static ClassDef? Class(string id) => System.Array.Find(Classes, c => c.Id == id);
public static BackgroundDef? Background(string id) => System.Array.Find(Backgrounds, b => b.Id == id);
public static IEnumerable<SpeciesDef> SpeciesOfClade(string cladeId)
{
foreach (var s in Species)
if (string.Equals(s.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
yield return s;
}
private static T Load<T>(System.Func<Theriapolis.Core.Data.ContentLoader, T> fn)
{
var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir);
return fn(loader);
}
}
+11 -3
View File
@@ -55,14 +55,22 @@ public partial class CodexStepper : HBoxContainer
SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsHorizontal = SizeFlags.ExpandFill,
ToggleMode = false, ToggleMode = false,
}; };
// Build a vbox child for two stacked labels. // Build a vbox child for two stacked labels. Button isn't a
// Container, so the vbox doesn't get auto-laid-out — anchor it to
// fill the button's rect explicitly. Without this, the vbox sits at
// (0,0) with intrinsic size and the labels render in the top-left
// corner instead of centered.
var vbox = new VBoxContainer var vbox = new VBoxContainer
{ {
MouseFilter = MouseFilterEnum.Ignore, MouseFilter = MouseFilterEnum.Ignore,
SizeFlagsHorizontal = SizeFlags.ExpandFill,
SizeFlagsVertical = SizeFlags.ExpandFill,
Alignment = BoxContainer.AlignmentMode.Center, Alignment = BoxContainer.AlignmentMode.Center,
}; };
vbox.AnchorRight = 1f;
vbox.AnchorBottom = 1f;
vbox.OffsetLeft = 0;
vbox.OffsetRight = 0;
vbox.OffsetTop = 0;
vbox.OffsetBottom = 0;
btn.AddChild(vbox); btn.AddChild(vbox);
var num = new Label var num = new Label