Files

727 lines
30 KiB
Markdown
Raw Permalink Normal View History

# 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.*