727 lines
30 KiB
Markdown
727 lines
30 KiB
Markdown
|
|
# 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: 1–2 days for scaffolding, ~1 day per step scene, 1–2 days for the popover and drag system, 2–3 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.*
|