Files
TheriapolisV3/GODOT_PORTING_GUIDE.md
T
Christopher Wiebe ee5439285c 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>
2026-05-02 19:35:03 -07:00

727 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.*