M6.1: Character creation wizard foundation (.tscn + Resource-based draft)
Pivots from M5's code-built UI to the editor-authorable .tscn pattern
recommended in GODOT_PORTING_GUIDE.md, after a session of fighting
Godot idioms with code-only layout. Default theme only; the parchment
Theme lands last per the guide's §12 build order so layout bugs surface
as layout bugs, not theming bugs.
GODOT_PORTING_GUIDE.md:
Authored by Claude Design as the canonical port reference. Maps the
React prototype's structure onto Godot 4.6 with concrete code sketches
and a build-order recommendation. Drove the M6 architecture.
Fonts/:
Cormorant Garamond (Medium + MediumItalic) and Crimson Pro (Regular +
Italic + SemiBold) under OFL — the React prototype's serif-display
and serif-body families. Not yet wired through CodexTheme.Build()
because theming is deferred; CodexTheme.LoadFontFromFonts already
picks them up automatically when the Theme pass lands.
Scenes/Wizard.tscn + Wizard.cs:
Wizard shell per guide §4: codex-header (title + folio counter) +
Stepper + Page (StepHost + Aside) + NavBar (Back / validation / Next).
All node lookups via unique-name (%) syntax; layout authored as a
scene file you can open in the editor. Step lifecycle drives the
Aside via signal binding. Stepper logic mirrors app.jsx — locked
iff some EARLIER step is unsatisfied; "type not yet implemented"
doesn't lock.
Scenes/Aside.tscn + Aside.cs:
Right-rail summary per guide §10. Single Refresh() rebuild on
CharacterDraft.Changed; cheap enough not to bother with partial
updates. Width 320 (was 380 before the layout overflow fix).
Scenes/Steps/IStep.cs + StepClade.cs:
Per-step Bind(draft) + Validate() contract. StepClade renders the
3-column clade card grid; click commits via CharacterDraft.Patch
which triggers the Resource.Changed signal that Aside and Wizard
both subscribe to.
UI/CharacterDraft.cs:
Resource (not Node) per guide §2.1. Mirrors app.jsx's `state` shape
exactly. Patch(dictionary) emits the inherited Resource.Changed
signal — listeners use `draft.Changed += handler` regardless of
which field changed. CodexContent provides lazy-loaded immutable
content tables (Clades, Species, Classes, Subclasses, Backgrounds).
Main.{cs,tscn}: Node → Control
When Main was a Node, Control children couldn't anchor to a real
parent rect — they sat at (0,0) at intrinsic min size. With wide
step content (3-column 200-px-card grid), the Wizard's min size
pushed the navbar beyond the viewport's right edge, hiding the Next
button on smaller windowed viewports. Making Main a full-rect-
anchored Control gives child scenes a proper rect to lay out in.
UI/Widgets/CodexStepper.cs:
Anchored the inner vbox to fill the button rect. Without this, the
vbox sat at the button's top-left at intrinsic size and labels
rendered in the corner — visible as the active-step label being
off-center from the highlight bar.
Verified at 1152x720 windowed and (separately) at fullscreen:
- 3-column card grid fits inside Wrap margins + Aside without
horizontal overflow
- Stepper labels centered under their highlight bars
- Next button visible after clade selection; future steps switch
to "coming soon" placeholder when clicked
- Aside summary fills in CLADE block on selection
Closes M6.1. Next per guide §12 build order: M6.2 — StepStats with
drag-drop (highest-risk piece, de-risk before easy steps).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,726 @@
|
||||
# Theriapolis → Godot 4.6 Porting Guide
|
||||
|
||||
A companion to `README.md`. This document maps the React/HTML prototype onto a Godot 4.6 project, file by file, with code sketches for the parts that are not obvious. It assumes you've read the existing README and have a working Godot 4.6 install.
|
||||
|
||||
> **Scope.** This guide covers the character-creation wizard only — the same surface the prototype covers. It does not address how the resulting character is consumed by the rest of the game; that handoff is described under "The handoff contract" at the end.
|
||||
|
||||
---
|
||||
|
||||
## 0. The honest summary
|
||||
|
||||
The port is **a UI rebuild, not a translation.** What carries over verbatim:
|
||||
|
||||
- The four content JSON files in `data/`.
|
||||
- The `state` shape used as the character record.
|
||||
- All math (`abilityMod`, modifier summing, validation predicates).
|
||||
- Step-gating rules and the wizard flow.
|
||||
- Drag/drop *semantics* (pool→slot, slot→slot, slot→pool).
|
||||
|
||||
What gets rebuilt in Godot's idiom:
|
||||
|
||||
- All views (HTML/CSS → Control scenes + Theme).
|
||||
- The hover-popover system (`TraitName`) → a custom `PopupPanel` Control.
|
||||
- HTML5 drag-and-drop → Godot's `_get_drag_data` / `_can_drop_data` / `_drop_data`.
|
||||
- The Tweaks panel — almost certainly delete it; bake one theme.
|
||||
|
||||
Plan on roughly: 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.*
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
@@ -5,7 +5,11 @@ using Theriapolis.GodotHost.UI;
|
||||
|
||||
namespace Theriapolis.GodotHost;
|
||||
|
||||
public partial class Main : Node
|
||||
// Control (not Node) so child Control scenes (Wizard, KitchenSink, etc.)
|
||||
// can anchor to a real rect that fills the viewport. With a plain Node
|
||||
// parent, anchors are ignored and Controls sit at (0,0) at intrinsic min
|
||||
// size, which causes wide content to overflow off the right edge.
|
||||
public partial class Main : Control
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
@@ -22,6 +26,7 @@ public partial class Main : Node
|
||||
ulong? worldMapSeed = null;
|
||||
bool runAssetTest = false;
|
||||
bool runCodexTest = false;
|
||||
bool runWizard = false;
|
||||
(ulong seed, int tx, int ty)? tacticalArgs = null;
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
@@ -30,6 +35,11 @@ public partial class Main : Node
|
||||
runCodexTest = true;
|
||||
break;
|
||||
}
|
||||
if (args[i] == "--wizard")
|
||||
{
|
||||
runWizard = true;
|
||||
break;
|
||||
}
|
||||
if (args[i] == "--smoke-test")
|
||||
{
|
||||
ulong seed = 12345UL;
|
||||
@@ -109,6 +119,15 @@ public partial class Main : Node
|
||||
return;
|
||||
}
|
||||
|
||||
if (runWizard)
|
||||
{
|
||||
foreach (Node child in GetChildren())
|
||||
child.QueueFree();
|
||||
var packed = ResourceLoader.Load<PackedScene>("res://Scenes/Wizard.tscn");
|
||||
AddChild(packed.Instantiate());
|
||||
return;
|
||||
}
|
||||
|
||||
GD.Print("Theriapolis.Godot host ready (M0 hello-world).");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
[ext_resource type="Script" path="res://Main.cs" id="1_main"]
|
||||
|
||||
[node name="Main" type="Node"]
|
||||
[node name="Main" type="Control"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_main")
|
||||
|
||||
[node name="Label" type="Label" parent="."]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using Godot;
|
||||
using Theriapolis.GodotHost.UI;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Right-rail summary of the in-progress character. Single
|
||||
/// <see cref="Refresh"/> rebuilds every section per
|
||||
/// GODOT_PORTING_GUIDE.md §10 — the panel is small enough that full
|
||||
/// rebuild is cheap and partial-update logic isn't worth it. Connect
|
||||
/// the draft via <see cref="SetDraft"/>; the Wizard does this on _Ready.
|
||||
/// </summary>
|
||||
public partial class Aside : MarginContainer
|
||||
{
|
||||
private CharacterDraft? _draft;
|
||||
private VBoxContainer _content = null!;
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
AddThemeConstantOverride("margin_left", 18);
|
||||
AddThemeConstantOverride("margin_right", 18);
|
||||
AddThemeConstantOverride("margin_top", 18);
|
||||
AddThemeConstantOverride("margin_bottom", 18);
|
||||
|
||||
_content = new VBoxContainer();
|
||||
_content.AddThemeConstantOverride("separation", 18);
|
||||
AddChild(_content);
|
||||
}
|
||||
|
||||
public void SetDraft(CharacterDraft draft)
|
||||
{
|
||||
_draft = draft;
|
||||
_draft.Changed += Refresh;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_draft is null || _content is null) return;
|
||||
foreach (var c in _content.GetChildren()) c.QueueFree();
|
||||
|
||||
_content.AddChild(new Label { Text = "SUMMARY" });
|
||||
AddBlock("Clade", CodexContent.Clade(_draft.CladeId)?.Name);
|
||||
AddBlock("Species", CodexContent.SpeciesById(_draft.SpeciesId)?.Name);
|
||||
AddBlock("Calling", CodexContent.Class(_draft.ClassId)?.Name);
|
||||
AddBlock("Background", CodexContent.Background(_draft.BackgroundId)?.Name);
|
||||
AddBlock("Name", string.IsNullOrEmpty(_draft.CharacterName) ? null : _draft.CharacterName);
|
||||
}
|
||||
|
||||
private void AddBlock(string label, string? value)
|
||||
{
|
||||
var v = new VBoxContainer();
|
||||
v.AddThemeConstantOverride("separation", 4);
|
||||
_content.AddChild(v);
|
||||
v.AddChild(new Label { Text = label.ToUpperInvariant() });
|
||||
v.AddChild(new Label { Text = value ?? "—" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[gd_scene load_steps=2 format=3 uid="uid://aside6m6v1"]
|
||||
|
||||
[ext_resource type="Script" path="res://Scenes/Aside.cs" id="1_aside"]
|
||||
|
||||
[node name="Aside" type="MarginContainer"]
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(320, 0)
|
||||
size_flags_horizontal = 0
|
||||
script = ExtResource("1_aside")
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Theriapolis.GodotHost.Scenes.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for character-creation step scenes. Mirrors
|
||||
/// <c>app.jsx</c>'s per-step <c>validate</c> pattern: each step inspects
|
||||
/// the shared draft and returns null when its requirements are met, or
|
||||
/// a short error string otherwise.
|
||||
///
|
||||
/// Step classes also extend <see cref="Godot.Control"/> so they can be
|
||||
/// parented into the wizard's StepHost. Bind() is called once after
|
||||
/// instantiation; Godot drives the lifecycle as usual.
|
||||
/// </summary>
|
||||
public interface IStep
|
||||
{
|
||||
void Bind(UI.CharacterDraft draft);
|
||||
string? Validate();
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using Godot;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.GodotHost.UI;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes.Steps;
|
||||
|
||||
/// <summary>
|
||||
/// Step I — Clade. Direct port of <c>StepClade</c> in
|
||||
/// <c>src/steps.jsx</c>: intro paragraph, then a card grid with one
|
||||
/// card per clade. Click selects via <see cref="CharacterDraft.Patch"/>.
|
||||
///
|
||||
/// Default theme only at this layer (per GODOT_PORTING_GUIDE.md §12 build
|
||||
/// order); the parchment look lands in the final theming pass.
|
||||
/// </summary>
|
||||
public partial class StepClade : VBoxContainer, IStep
|
||||
{
|
||||
private CharacterDraft _draft = null!;
|
||||
private GridContainer _grid = null!;
|
||||
|
||||
public void Bind(CharacterDraft draft)
|
||||
{
|
||||
_draft = draft;
|
||||
_draft.Changed += Refresh;
|
||||
Build();
|
||||
}
|
||||
|
||||
public string? Validate() => string.IsNullOrEmpty(_draft?.CladeId) ? "Pick a clade." : null;
|
||||
|
||||
private void Build()
|
||||
{
|
||||
AddThemeConstantOverride("separation", 16);
|
||||
|
||||
var intro = new VBoxContainer();
|
||||
intro.AddThemeConstantOverride("separation", 6);
|
||||
AddChild(intro);
|
||||
intro.AddChild(new Label { Text = "FOLIO I · CLADE" });
|
||||
intro.AddChild(new Label { Text = "Choose a Clade" });
|
||||
intro.AddChild(new Label
|
||||
{
|
||||
Text = "The broad mammalian family of your line. Clade defines the largest "
|
||||
+ "strokes — predator or prey, communal or solitary, scent-driven or "
|
||||
+ "sight-driven. Each clade carries inherited traits and limits that "
|
||||
+ "no character escapes.",
|
||||
AutowrapMode = TextServer.AutowrapMode.WordSmart,
|
||||
CustomMinimumSize = new Vector2(0, 0),
|
||||
});
|
||||
|
||||
_grid = new GridContainer
|
||||
{
|
||||
Columns = 3,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
};
|
||||
_grid.AddThemeConstantOverride("h_separation", 16);
|
||||
_grid.AddThemeConstantOverride("v_separation", 16);
|
||||
AddChild(_grid);
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_grid is null) return;
|
||||
foreach (var c in _grid.GetChildren()) c.QueueFree();
|
||||
foreach (var clade in CodexContent.Clades)
|
||||
_grid.AddChild(BuildCard(clade));
|
||||
}
|
||||
|
||||
private Control BuildCard(CladeDef clade)
|
||||
{
|
||||
bool selected = _draft.CladeId == clade.Id;
|
||||
|
||||
var btn = new Button
|
||||
{
|
||||
Text = "",
|
||||
Flat = false,
|
||||
ToggleMode = true,
|
||||
ButtonPressed = selected,
|
||||
FocusMode = Control.FocusModeEnum.None,
|
||||
// 200 wide so 3 cards + separators (≈ 632) + page margins +
|
||||
// Aside fit inside ≥ 1024-px viewports (the smaller-screen
|
||||
// floor we want to support). Height held at 200 so the inner
|
||||
// labels render at readable size without a content-driven
|
||||
// height collapse (Button isn't a Container, so child vbox
|
||||
// height doesn't bubble up to the button's intrinsic min size).
|
||||
CustomMinimumSize = new Vector2(200, 200),
|
||||
ClipText = false,
|
||||
Alignment = HorizontalAlignment.Left,
|
||||
};
|
||||
btn.Pressed += () =>
|
||||
{
|
||||
// Default species for the new clade — match React app.jsx:
|
||||
// when clade changes, species defaults to first species in clade.
|
||||
string speciesId = "";
|
||||
foreach (var s in CodexContent.SpeciesOfClade(clade.Id))
|
||||
{
|
||||
speciesId = s.Id;
|
||||
break;
|
||||
}
|
||||
_draft.Patch(new Godot.Collections.Dictionary
|
||||
{
|
||||
{ "clade_id", clade.Id },
|
||||
{ "species_id", speciesId },
|
||||
});
|
||||
};
|
||||
|
||||
// Label content stacked inside the button via an anchored VBoxContainer
|
||||
// (Button isn't a Container, so we anchor the vbox to fill the button's
|
||||
// rect and let the children flow within it).
|
||||
var box = new VBoxContainer
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
};
|
||||
box.AnchorRight = 1f;
|
||||
box.AnchorBottom = 1f;
|
||||
box.OffsetLeft = 12;
|
||||
box.OffsetTop = 12;
|
||||
box.OffsetRight = -12;
|
||||
box.OffsetBottom = -12;
|
||||
box.AddThemeConstantOverride("separation", 6);
|
||||
btn.AddChild(box);
|
||||
|
||||
box.AddChild(new Label { Text = clade.Name, MouseFilter = MouseFilterEnum.Ignore });
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = clade.Kind.ToUpperInvariant(),
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
});
|
||||
|
||||
if (clade.AbilityMods.Count > 0)
|
||||
{
|
||||
var modsRow = new HBoxContainer { MouseFilter = MouseFilterEnum.Ignore };
|
||||
modsRow.AddThemeConstantOverride("separation", 8);
|
||||
box.AddChild(modsRow);
|
||||
foreach (var (k, v) in clade.AbilityMods)
|
||||
modsRow.AddChild(new Label
|
||||
{
|
||||
Text = $"{k} {(v >= 0 ? "+" : "")}{v}",
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
});
|
||||
}
|
||||
|
||||
if (clade.Traits.Length > 0)
|
||||
{
|
||||
box.AddChild(new Label
|
||||
{
|
||||
Text = $"{clade.Traits.Length} traits, {clade.Detriments.Length} detriments",
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
});
|
||||
}
|
||||
|
||||
return btn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Godot;
|
||||
|
||||
namespace Theriapolis.GodotHost.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Character creation wizard shell. Mirrors <c>src/app.jsx</c> per
|
||||
/// GODOT_PORTING_GUIDE.md §4: header + stepper + page (step + aside) +
|
||||
/// nav bar. Owns the <see cref="CharacterDraft"/> resource and dispatches
|
||||
/// each step's content into the StepHost.
|
||||
///
|
||||
/// Default theme only at this layer — per guide §12 (build order),
|
||||
/// the parchment Theme lands as a final pass once structural correctness
|
||||
/// is verified. Until then, font/colour issues are clearly font/colour
|
||||
/// issues, not layout issues.
|
||||
/// </summary>
|
||||
public partial class Wizard : Control
|
||||
{
|
||||
[Signal] public delegate void BackToTitleEventHandler();
|
||||
|
||||
private static readonly string[] StepKeys =
|
||||
{ "clade", "species", "class", "subclass", "background", "stats", "skills", "review" };
|
||||
private static readonly string[] StepNames =
|
||||
{ "Clade", "Species", "Calling", "Subclass", "History", "Abilities", "Skills", "Sign" };
|
||||
|
||||
public UI.CharacterDraft Character { get; private set; } = null!;
|
||||
|
||||
private UI.Widgets.CodexStepper _stepper = null!;
|
||||
private Control _stepHost = null!;
|
||||
private Label _folioLabel = null!;
|
||||
private Label _validation = null!;
|
||||
private Label _navProgress = null!;
|
||||
private Button _backBtn = null!;
|
||||
private Button _nextBtn = null!;
|
||||
|
||||
private int _step;
|
||||
private Steps.IStep? _activeStep;
|
||||
private static readonly System.Type?[] StepTypes =
|
||||
{
|
||||
typeof(Steps.StepClade), // 0 Clade — implemented
|
||||
null, // 1 Species
|
||||
null, // 2 Calling
|
||||
null, // 3 Subclass
|
||||
null, // 4 History
|
||||
null, // 5 Abilities
|
||||
null, // 6 Skills
|
||||
null, // 7 Sign
|
||||
};
|
||||
|
||||
public override void _Ready()
|
||||
{
|
||||
Character = new UI.CharacterDraft();
|
||||
|
||||
_stepper = GetNode<UI.Widgets.CodexStepper>("%Stepper");
|
||||
_stepHost = GetNode<Control>("%StepHost");
|
||||
_folioLabel = GetNode<Label>("%FolioLabel");
|
||||
_validation = GetNode<Label>("%ValidationLabel");
|
||||
_navProgress = GetNode<Label>("%NavProgress");
|
||||
_backBtn = GetNode<Button>("%BackButton");
|
||||
_nextBtn = GetNode<Button>("%NextButton");
|
||||
|
||||
var aside = GetNode<Aside>("%Aside");
|
||||
aside.SetDraft(Character);
|
||||
|
||||
_stepper.StepClicked += OnStepperClicked;
|
||||
_backBtn.Pressed += OnBackPressed;
|
||||
_nextBtn.Pressed += OnNextPressed;
|
||||
Character.Changed += UpdateChrome;
|
||||
|
||||
SwitchToStep(0);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Step lifecycle
|
||||
|
||||
private void SwitchToStep(int index)
|
||||
{
|
||||
if (index < 0 || index >= StepKeys.Length) return;
|
||||
_step = index;
|
||||
|
||||
foreach (var c in _stepHost.GetChildren()) c.QueueFree();
|
||||
_activeStep = null;
|
||||
|
||||
var t = StepTypes[index];
|
||||
if (t is null)
|
||||
{
|
||||
_stepHost.AddChild(new Label
|
||||
{
|
||||
Text = $"{StepNames[index]} step — coming soon.",
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var instance = (Steps.IStep)System.Activator.CreateInstance(t)!;
|
||||
_activeStep = instance;
|
||||
instance.Bind(Character);
|
||||
_stepHost.AddChild((Control)instance);
|
||||
}
|
||||
|
||||
UpdateChrome();
|
||||
}
|
||||
|
||||
private void OnStepperClicked(int index)
|
||||
{
|
||||
if (index <= _step) { SwitchToStep(index); return; }
|
||||
// Forward jump requires current step satisfied. Unimplemented future
|
||||
// steps still accept the jump — SwitchToStep will show a placeholder.
|
||||
if (_activeStep?.Validate() is not null) return;
|
||||
SwitchToStep(index);
|
||||
}
|
||||
|
||||
private void OnBackPressed()
|
||||
{
|
||||
if (_step == 0) { EmitSignal(SignalName.BackToTitle); return; }
|
||||
SwitchToStep(_step - 1);
|
||||
}
|
||||
|
||||
private void OnNextPressed()
|
||||
{
|
||||
if (_step < StepKeys.Length - 1) SwitchToStep(_step + 1);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Chrome (header, stepper, nav-bar) refresh
|
||||
|
||||
private void UpdateChrome()
|
||||
{
|
||||
_folioLabel.Text = $"Folio {Roman(_step + 1)} of VIII — {StepNames[_step]}";
|
||||
|
||||
bool valid = _activeStep?.Validate() is null;
|
||||
string? err = _activeStep?.Validate();
|
||||
_validation.Text = err ?? (_step == StepKeys.Length - 1 ? "Ready to sign" : "Folio complete");
|
||||
_nextBtn.Disabled = !valid;
|
||||
_nextBtn.Visible = _step < StepKeys.Length - 1;
|
||||
_backBtn.Text = _step == 0 ? "← Title" : "← Back";
|
||||
_navProgress.Text = $"{_step + 1} / {StepKeys.Length}";
|
||||
|
||||
RebuildStepperStates(valid);
|
||||
}
|
||||
|
||||
private void RebuildStepperStates(bool currentSatisfied)
|
||||
{
|
||||
// Mirrors app.jsx's lock semantics: a step is Locked iff some EARLIER
|
||||
// step is unsatisfied. "Type not yet implemented" doesn't affect the
|
||||
// lock state — clicking an unimplemented step just shows a placeholder.
|
||||
var states = new UI.Widgets.CodexStepper.StepState[StepNames.Length];
|
||||
for (int i = 0; i < StepNames.Length; i++)
|
||||
{
|
||||
if (i == _step)
|
||||
states[i] = UI.Widgets.CodexStepper.StepState.Active;
|
||||
else if (i < _step)
|
||||
states[i] = UI.Widgets.CodexStepper.StepState.Complete;
|
||||
else
|
||||
states[i] = currentSatisfied
|
||||
? UI.Widgets.CodexStepper.StepState.Pending
|
||||
: UI.Widgets.CodexStepper.StepState.Locked;
|
||||
}
|
||||
_stepper.SetSteps(StepNames, states);
|
||||
}
|
||||
|
||||
private static string Roman(int n) => n switch
|
||||
{
|
||||
1 => "I", 2 => "II", 3 => "III", 4 => "IV",
|
||||
5 => "V", 6 => "VI", 7 => "VII", 8 => "VIII",
|
||||
_ => n.ToString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
[gd_scene load_steps=4 format=3 uid="uid://wizard6m6v1"]
|
||||
|
||||
[ext_resource type="Script" path="res://Scenes/Wizard.cs" id="1_wizard"]
|
||||
[ext_resource type="Script" path="res://UI/Widgets/CodexStepper.cs" id="2_stepper"]
|
||||
[ext_resource type="PackedScene" path="res://Scenes/Aside.tscn" id="3_aside"]
|
||||
|
||||
[node name="Wizard" type="Control"]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_wizard")
|
||||
|
||||
[node name="Wrap" type="MarginContainer" parent="."]
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
theme_override_constants/margin_left = 36
|
||||
theme_override_constants/margin_right = 36
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="Layout" type="VBoxContainer" parent="Wrap"]
|
||||
|
||||
[node name="Header" type="HBoxContainer" parent="Wrap/Layout"]
|
||||
theme_override_constants/separation = 24
|
||||
|
||||
[node name="TitleCol" type="VBoxContainer" parent="Wrap/Layout/Header"]
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Title" type="Label" parent="Wrap/Layout/Header/TitleCol"]
|
||||
text = "THERIAPOLIS · CODEX OF BECOMING"
|
||||
|
||||
[node name="FolioLabel" type="Label" parent="Wrap/Layout/Header/TitleCol"]
|
||||
unique_name_in_owner = true
|
||||
text = "Folio I of VIII — Clade"
|
||||
|
||||
[node name="MetaLabel" type="Label" parent="Wrap/Layout/Header"]
|
||||
text = "PORT/GODOT · M6"
|
||||
|
||||
[node name="Stepper" type="HBoxContainer" parent="Wrap/Layout"]
|
||||
unique_name_in_owner = true
|
||||
size_flags_horizontal = 3
|
||||
script = ExtResource("2_stepper")
|
||||
|
||||
[node name="Page" type="HBoxContainer" parent="Wrap/Layout"]
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="PageMain" type="MarginContainer" parent="Wrap/Layout/Page"]
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
theme_override_constants/margin_left = 12
|
||||
theme_override_constants/margin_right = 28
|
||||
theme_override_constants/margin_top = 16
|
||||
theme_override_constants/margin_bottom = 16
|
||||
|
||||
[node name="Scroll" type="ScrollContainer" parent="Wrap/Layout/Page/PageMain"]
|
||||
size_flags_horizontal = 3
|
||||
size_flags_vertical = 3
|
||||
horizontal_scroll_mode = 0
|
||||
|
||||
[node name="StepHost" type="VBoxContainer" parent="Wrap/Layout/Page/PageMain/Scroll"]
|
||||
unique_name_in_owner = true
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="Aside" parent="Wrap/Layout/Page" instance=ExtResource("3_aside")]
|
||||
|
||||
[node name="NavBar" type="HBoxContainer" parent="Wrap/Layout"]
|
||||
theme_override_constants/separation = 24
|
||||
|
||||
[node name="BackButton" type="Button" parent="Wrap/Layout/NavBar"]
|
||||
unique_name_in_owner = true
|
||||
text = "← Back"
|
||||
|
||||
[node name="Spacer" type="Control" parent="Wrap/Layout/NavBar"]
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="ValidationLabel" type="Label" parent="Wrap/Layout/NavBar"]
|
||||
unique_name_in_owner = true
|
||||
text = ""
|
||||
|
||||
[node name="NavProgress" type="Label" parent="Wrap/Layout/NavBar"]
|
||||
unique_name_in_owner = true
|
||||
text = "1 / 8"
|
||||
|
||||
[node name="NextButton" type="Button" parent="Wrap/Layout/NavBar"]
|
||||
unique_name_in_owner = true
|
||||
text = "Next ›"
|
||||
@@ -0,0 +1,106 @@
|
||||
using Godot;
|
||||
using System.Collections.Generic;
|
||||
using Theriapolis.Core.Data;
|
||||
using Theriapolis.GodotHost.Platform;
|
||||
|
||||
namespace Theriapolis.GodotHost.UI;
|
||||
|
||||
/// <summary>
|
||||
/// In-progress character record. Resource (not Node) per
|
||||
/// GODOT_PORTING_GUIDE.md §2.1: serializable via ResourceSaver.save,
|
||||
/// inspectable in the editor, doesn't need tree presence.
|
||||
///
|
||||
/// Mirrors <c>app.jsx</c>'s <c>state</c> shape — clade/species/class/etc.,
|
||||
/// stat assignment, chosen skills, name. Mutate via <see cref="Patch"/> so
|
||||
/// every change emits <see cref="Changed"/>; the Aside and Wizard listen
|
||||
/// and re-render on that single signal.
|
||||
/// </summary>
|
||||
[GlobalClass]
|
||||
public partial class CharacterDraft : Resource
|
||||
{
|
||||
// NB: Resource defines its own `Changed` signal (emitted via EmitChanged()).
|
||||
// We piggyback on it instead of declaring our own to avoid a name clash —
|
||||
// listeners do `draft.Changed += ...` exactly the same way either way.
|
||||
|
||||
[Export] public string CladeId { get; set; } = "";
|
||||
[Export] public string SpeciesId { get; set; } = "";
|
||||
[Export] public string ClassId { get; set; } = "";
|
||||
[Export] public string SubclassId { get; set; } = "";
|
||||
[Export] public string BackgroundId { get; set; } = "";
|
||||
|
||||
/// <summary>"array" or "roll" — assignment method for ability scores.</summary>
|
||||
[Export] public string StatMethod { get; set; } = "array";
|
||||
[Export] public Godot.Collections.Array<int> StatPool { get; set; }
|
||||
= new() { 15, 14, 13, 12, 10, 8 };
|
||||
[Export] public Godot.Collections.Dictionary StatAssign { get; set; } = new();
|
||||
|
||||
[Export] public Godot.Collections.Array<string> ChosenSkills { get; set; } = new();
|
||||
[Export] public string CharacterName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Mutate one or more fields, then emit Changed once. Mirrors
|
||||
/// the JS prototype's <c>set(patch)</c> from app.jsx.
|
||||
/// </summary>
|
||||
public void Patch(Godot.Collections.Dictionary patch)
|
||||
{
|
||||
foreach (var key in patch.Keys)
|
||||
{
|
||||
string k = (string)key;
|
||||
switch (k)
|
||||
{
|
||||
case "clade_id": CladeId = (string)patch[key]; break;
|
||||
case "species_id": SpeciesId = (string)patch[key]; break;
|
||||
case "class_id": ClassId = (string)patch[key]; break;
|
||||
case "subclass_id": SubclassId = (string)patch[key]; break;
|
||||
case "background_id": BackgroundId = (string)patch[key]; break;
|
||||
case "stat_method": StatMethod = (string)patch[key]; break;
|
||||
case "stat_pool": StatPool = (Godot.Collections.Array<int>)patch[key]; break;
|
||||
case "stat_assign": StatAssign = (Godot.Collections.Dictionary)patch[key]; break;
|
||||
case "chosen_skills": ChosenSkills = (Godot.Collections.Array<string>)patch[key]; break;
|
||||
case "character_name":CharacterName = (string)patch[key]; break;
|
||||
default:
|
||||
GD.PushWarning($"[CharacterDraft] unknown patch key: {k}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
EmitChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazy-loaded immutable content tables. Replaces the React prototype's
|
||||
/// data.jsx + autoload Content per GODOT_PORTING_GUIDE.md §2.2 — same
|
||||
/// purpose, idiomatic C#.
|
||||
/// </summary>
|
||||
public static class CodexContent
|
||||
{
|
||||
private static CladeDef[]? _clades;
|
||||
private static SpeciesDef[]? _species;
|
||||
private static ClassDef[]? _classes;
|
||||
private static SubclassDef[]? _subclasses;
|
||||
private static BackgroundDef[]? _backgrounds;
|
||||
|
||||
public static CladeDef[] Clades => _clades ??= Load(l => l.LoadClades());
|
||||
public static SpeciesDef[] Species => _species ??= Load(l => l.LoadSpecies(Clades));
|
||||
public static ClassDef[] Classes => _classes ??= Load(l => l.LoadClasses());
|
||||
public static SubclassDef[] Subclasses => _subclasses ??= Load(l => l.LoadSubclasses(Classes));
|
||||
public static BackgroundDef[] Backgrounds => _backgrounds ??= Load(l => l.LoadBackgrounds());
|
||||
|
||||
public static CladeDef? Clade(string id) => System.Array.Find(Clades, c => c.Id == id);
|
||||
public static SpeciesDef? SpeciesById(string id) => System.Array.Find(Species, s => s.Id == id);
|
||||
public static ClassDef? Class(string id) => System.Array.Find(Classes, c => c.Id == id);
|
||||
public static BackgroundDef? Background(string id) => System.Array.Find(Backgrounds, b => b.Id == id);
|
||||
|
||||
public static IEnumerable<SpeciesDef> SpeciesOfClade(string cladeId)
|
||||
{
|
||||
foreach (var s in Species)
|
||||
if (string.Equals(s.CladeId, cladeId, System.StringComparison.OrdinalIgnoreCase))
|
||||
yield return s;
|
||||
}
|
||||
|
||||
private static T Load<T>(System.Func<Theriapolis.Core.Data.ContentLoader, T> fn)
|
||||
{
|
||||
var loader = new Theriapolis.Core.Data.ContentLoader(ContentPaths.DataDir);
|
||||
return fn(loader);
|
||||
}
|
||||
}
|
||||
@@ -55,14 +55,22 @@ public partial class CodexStepper : HBoxContainer
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
ToggleMode = false,
|
||||
};
|
||||
// Build a vbox child for two stacked labels.
|
||||
// Build a vbox child for two stacked labels. Button isn't a
|
||||
// Container, so the vbox doesn't get auto-laid-out — anchor it to
|
||||
// fill the button's rect explicitly. Without this, the vbox sits at
|
||||
// (0,0) with intrinsic size and the labels render in the top-left
|
||||
// corner instead of centered.
|
||||
var vbox = new VBoxContainer
|
||||
{
|
||||
MouseFilter = MouseFilterEnum.Ignore,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
Alignment = BoxContainer.AlignmentMode.Center,
|
||||
};
|
||||
vbox.AnchorRight = 1f;
|
||||
vbox.AnchorBottom = 1f;
|
||||
vbox.OffsetLeft = 0;
|
||||
vbox.OffsetRight = 0;
|
||||
vbox.OffsetTop = 0;
|
||||
vbox.OffsetBottom = 0;
|
||||
btn.AddChild(vbox);
|
||||
|
||||
var num = new Label
|
||||
|
||||
Reference in New Issue
Block a user