Compare commits
14 Commits
09922e5fd3
...
d9caf1dff6
| Author | SHA1 | Date | |
|---|---|---|---|
| d9caf1dff6 | |||
| c933d12504 | |||
| 58880554c2 | |||
| fb541d05cb | |||
| 6ef71aaa31 | |||
| 4698732285 | |||
| 578b3e86c9 | |||
| 5f1ebdf840 | |||
| bca04e3a2e | |||
| 35c4586792 | |||
| 9a02c9b038 | |||
| c4aa74da5a | |||
| 400445f312 | |||
| cc365c8b73 |
@@ -494,7 +494,13 @@ quest log, shop, pause/settings, dialogue, combat HUD, main exploration window)
|
|||||||
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
plus a `README.md` handoff. **Not production code** — each screen is recreated as
|
||||||
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
a **Godot 4.7 Control-node scene** pulling from a shared **`Theme`** resource
|
||||||
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
(the README's palette, three fonts, and reusable styleboxes keep the look one
|
||||||
system). Each `.dc.html`'s `Component` class is the **behavior spec** — the
|
system). **Author these scenes editor-first:** the layout lives in the `.tscn`
|
||||||
|
as real Control nodes with the `Theme` and type-variations set in the editor, so
|
||||||
|
the scene *previews* and can be arranged in the Godot editor — do **not**
|
||||||
|
build the node tree in `_ready()`. Scripts hold only on-load work: `@onready`
|
||||||
|
node refs, data/DM wiring, animations, and binding state into fixed authored
|
||||||
|
nodes (see ADR [0001](docs/adr/0001-editor-first-ui-scenes.md)). Each
|
||||||
|
`.dc.html`'s `Component` class is the **behavior spec** — the
|
||||||
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
combat turn manager, dialogue graph, shop economy, and point-buy creation are the
|
||||||
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
highest-value logic to port; the seed-data maps (items, quests, locations, units,
|
||||||
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
abilities) are starting `Resource`s. Dashed "art slots" are placeholders for real
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
[gd_resource type="Theme" format=3]
|
[gd_resource type="Theme" format=3]
|
||||||
|
|
||||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_cu4xe"]
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf" id="1_605w4"]
|
||||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"]
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf" id="2_4lwr2"]
|
||||||
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_4lwr2"]
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf" id="3_eckwq"]
|
||||||
|
[ext_resource type="FontFile" path="res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf" id="4_i0b23"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_eckwq"]
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_cqbvn"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_i0b23"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -22,7 +23,7 @@ corner_radius_top_right = 3
|
|||||||
corner_radius_bottom_right = 3
|
corner_radius_bottom_right = 3
|
||||||
corner_radius_bottom_left = 3
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_cqbvn"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -38,7 +39,7 @@ corner_radius_top_right = 3
|
|||||||
corner_radius_bottom_right = 3
|
corner_radius_bottom_right = 3
|
||||||
corner_radius_bottom_left = 3
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_q5gpk"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -54,7 +55,7 @@ corner_radius_top_right = 3
|
|||||||
corner_radius_bottom_right = 3
|
corner_radius_bottom_right = 3
|
||||||
corner_radius_bottom_left = 3
|
corner_radius_bottom_left = 3
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bbpmc"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -70,7 +71,57 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_73lme"]
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_ube3r"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.05490196, 0.043137256, 0.03529412, 0.85)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.07058824, 0.05882353, 0.043137256, 0.7)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.41960785, 0.38039216, 0.32156864, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.039215688, 0.03137255, 0.023529412, 0.9)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5176471, 0.24705882, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -86,7 +137,7 @@ corner_radius_top_right = 6
|
|||||||
corner_radius_bottom_right = 6
|
corner_radius_bottom_right = 6
|
||||||
corner_radius_bottom_left = 6
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_js3tr"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -103,7 +154,73 @@ corner_radius_top_right = 6
|
|||||||
corner_radius_bottom_right = 6
|
corner_radius_bottom_right = 6
|
||||||
corner_radius_bottom_left = 6
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ube3r"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jly1r"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_lw8lk"]
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.9019608, 0.8509804, 0.72156864, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.94509804, 0.9098039, 0.8235294, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.6627451, 0.5803922, 0.39215687, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nmowx"]
|
||||||
|
content_margin_left = 12.0
|
||||||
|
content_margin_top = 6.0
|
||||||
|
content_margin_right = 12.0
|
||||||
|
content_margin_bottom = 6.0
|
||||||
|
bg_color = Color(0.8509804, 0.7882353, 0.63529414, 1)
|
||||||
|
border_width_left = 1
|
||||||
|
border_width_top = 1
|
||||||
|
border_width_right = 1
|
||||||
|
border_width_bottom = 1
|
||||||
|
border_color = Color(0.3529412, 0.2627451, 0.14901961, 1)
|
||||||
|
corner_radius_top_left = 8
|
||||||
|
corner_radius_top_right = 8
|
||||||
|
corner_radius_bottom_right = 8
|
||||||
|
corner_radius_bottom_left = 8
|
||||||
|
|
||||||
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -119,7 +236,7 @@ corner_radius_top_right = 6
|
|||||||
corner_radius_bottom_right = 6
|
corner_radius_bottom_right = 6
|
||||||
corner_radius_bottom_left = 6
|
corner_radius_bottom_left = 6
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_2tooq"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -135,7 +252,7 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_4kten"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -151,7 +268,7 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p6wtm"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_5u8fh"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -167,7 +284,7 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_0fjdt"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_p0qbh"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -183,7 +300,7 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ns3kg"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_6y6ak"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -199,9 +316,9 @@ corner_radius_top_right = 4
|
|||||||
corner_radius_bottom_right = 4
|
corner_radius_bottom_right = 4
|
||||||
corner_radius_bottom_left = 4
|
corner_radius_bottom_left = 4
|
||||||
|
|
||||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_jly1r"]
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_txxuc"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_lw8lk"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_7d6ug"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -217,7 +334,7 @@ corner_radius_top_right = 14
|
|||||||
corner_radius_bottom_right = 14
|
corner_radius_bottom_right = 14
|
||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_nh0e7"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_najyk"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -233,7 +350,7 @@ corner_radius_top_right = 14
|
|||||||
corner_radius_bottom_right = 14
|
corner_radius_bottom_right = 14
|
||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_dtr07"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_jtb1k"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -249,9 +366,9 @@ corner_radius_top_right = 14
|
|||||||
corner_radius_bottom_right = 14
|
corner_radius_bottom_right = 14
|
||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_nmowx"]
|
[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_wmbj4"]
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_oi067"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_xdylb"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -267,7 +384,7 @@ corner_radius_top_right = 14
|
|||||||
corner_radius_bottom_right = 14
|
corner_radius_bottom_right = 14
|
||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_ukblj"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pjnll"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -283,7 +400,7 @@ corner_radius_top_right = 14
|
|||||||
corner_radius_bottom_right = 14
|
corner_radius_bottom_right = 14
|
||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_utt73"]
|
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_klelq"]
|
||||||
content_margin_left = 12.0
|
content_margin_left = 12.0
|
||||||
content_margin_top = 6.0
|
content_margin_top = 6.0
|
||||||
content_margin_right = 12.0
|
content_margin_right = 12.0
|
||||||
@@ -300,54 +417,72 @@ corner_radius_bottom_right = 14
|
|||||||
corner_radius_bottom_left = 14
|
corner_radius_bottom_left = 14
|
||||||
|
|
||||||
[resource]
|
[resource]
|
||||||
default_font = ExtResource("3_4lwr2")
|
default_font = ExtResource("3_eckwq")
|
||||||
default_font_size = 18
|
default_font_size = 18
|
||||||
Accent/base_type = &"Label"
|
Accent/base_type = &"Label"
|
||||||
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||||
Accent/font_sizes/font_size = 24
|
Accent/font_sizes/font_size = 24
|
||||||
Accent/fonts/font = ExtResource("1_cu4xe")
|
Accent/fonts/font = ExtResource("1_605w4")
|
||||||
Chip/base_type = &"Button"
|
Chip/base_type = &"Button"
|
||||||
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
Chip/font_sizes/font_size = 11
|
Chip/font_sizes/font_size = 11
|
||||||
Chip/fonts/font = ExtResource("2_605w4")
|
Chip/fonts/font = ExtResource("2_4lwr2")
|
||||||
Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq")
|
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
|
||||||
Chip/styles/hover = SubResource("StyleBoxFlat_i0b23")
|
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
|
||||||
Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn")
|
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
|
||||||
Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk")
|
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
|
||||||
DarkPanel/base_type = &"PanelContainer"
|
DarkPanel/base_type = &"PanelContainer"
|
||||||
DarkPanel/styles/panel = SubResource("StyleBoxFlat_bbpmc")
|
DarkPanel/styles/panel = SubResource("StyleBoxFlat_js3tr")
|
||||||
|
DockButton/base_type = &"Button"
|
||||||
|
DockButton/colors/font_color = Color(0.7882353, 0.7411765, 0.6392157, 1)
|
||||||
|
DockButton/styles/focus = SubResource("StyleBoxEmpty_ube3r")
|
||||||
|
DockButton/styles/hover = SubResource("StyleBoxFlat_2tooq")
|
||||||
|
DockButton/styles/normal = SubResource("StyleBoxFlat_4kten")
|
||||||
|
DockButton/styles/pressed = SubResource("StyleBoxFlat_p6wtm")
|
||||||
Heading/base_type = &"Label"
|
Heading/base_type = &"Label"
|
||||||
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
Heading/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||||
Heading/font_sizes/font_size = 34
|
Heading/font_sizes/font_size = 34
|
||||||
Heading/fonts/font = ExtResource("3_4lwr2")
|
Heading/fonts/font = ExtResource("3_eckwq")
|
||||||
ItemTile/base_type = &"PanelContainer"
|
ItemTile/base_type = &"PanelContainer"
|
||||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme")
|
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
|
||||||
ItemTileEmpty/base_type = &"PanelContainer"
|
ItemTileEmpty/base_type = &"PanelContainer"
|
||||||
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr")
|
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
|
||||||
Mono/base_type = &"Label"
|
Mono/base_type = &"Label"
|
||||||
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
Mono/font_sizes/font_size = 13
|
Mono/font_sizes/font_size = 13
|
||||||
Mono/fonts/font = ExtResource("2_605w4")
|
Mono/fonts/font = ExtResource("2_4lwr2")
|
||||||
|
ParchmentButton/base_type = &"Button"
|
||||||
|
ParchmentButton/colors/font_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||||
|
ParchmentButton/colors/font_disabled_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
|
ParchmentButton/styles/disabled = SubResource("StyleBoxFlat_jly1r")
|
||||||
|
ParchmentButton/styles/focus = SubResource("StyleBoxEmpty_lw8lk")
|
||||||
|
ParchmentButton/styles/hover = SubResource("StyleBoxFlat_nh0e7")
|
||||||
|
ParchmentButton/styles/normal = SubResource("StyleBoxFlat_dtr07")
|
||||||
|
ParchmentButton/styles/pressed = SubResource("StyleBoxFlat_nmowx")
|
||||||
ParchmentCard/base_type = &"PanelContainer"
|
ParchmentCard/base_type = &"PanelContainer"
|
||||||
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_ube3r")
|
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
|
||||||
ParchmentInset/base_type = &"PanelContainer"
|
ParchmentInset/base_type = &"PanelContainer"
|
||||||
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq")
|
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
|
||||||
PrimaryCTA/base_type = &"Button"
|
PrimaryCTA/base_type = &"Button"
|
||||||
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
PrimaryCTA/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
PrimaryCTA/colors/font_disabled_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||||
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_4kten")
|
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
|
||||||
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm")
|
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
|
||||||
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt")
|
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
|
||||||
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
|
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_6y6ak")
|
||||||
|
RichTextLabel/colors/default_color = Color(0.2901961, 0.24705882, 0.17254902, 1)
|
||||||
|
RichTextLabel/font_sizes/normal_font_size = 20
|
||||||
|
RichTextLabel/fonts/italics_font = ExtResource("4_i0b23")
|
||||||
|
RichTextLabel/fonts/normal_font = ExtResource("3_eckwq")
|
||||||
Tab/base_type = &"Button"
|
Tab/base_type = &"Button"
|
||||||
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
Tab/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||||
Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r")
|
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
|
||||||
Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk")
|
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
|
||||||
Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7")
|
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
|
||||||
Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07")
|
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
|
||||||
TabActive/base_type = &"Button"
|
TabActive/base_type = &"Button"
|
||||||
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||||
TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx")
|
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
|
||||||
TabActive/styles/hover = SubResource("StyleBoxFlat_oi067")
|
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
|
||||||
TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj")
|
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
|
||||||
TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73")
|
TabActive/styles/pressed = SubResource("StyleBoxFlat_klelq")
|
||||||
|
|||||||
@@ -12,6 +12,49 @@ Put here:
|
|||||||
|
|
||||||
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
Cross-cutting design (anything touching both client and api) goes in the root [`/docs`](../../docs), not here.
|
||||||
|
|
||||||
|
## UI scene conventions (shell + all screens)
|
||||||
|
|
||||||
|
Screens are **authored editor-first** — the layout lives in the `.tscn`, not in
|
||||||
|
code. See ADR [0001](../../docs/adr/0001-editor-first-ui-scenes.md) for the why;
|
||||||
|
this is the how. Reference: `scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
|
||||||
|
+ `scripts/ui/shell/*.gd`.
|
||||||
|
|
||||||
|
- **The `.tscn` owns the tree.** Author every node in the editor: containers,
|
||||||
|
panels, labels, buttons, art-slot placeholders. Assign the shared theme on the
|
||||||
|
scene root (`theme = game_theme.tres`) and set each node's `theme_type_variation`
|
||||||
|
(from `ThemeKeys`) in the editor, so the scene previews themed. Instance
|
||||||
|
sub-screens (`PackedScene` ext_resource) rather than rebuilding them.
|
||||||
|
- **The script does on-load work only.** `@onready var x := $Path/To/Node` refs,
|
||||||
|
signal/service wiring, animations, and binding state into the authored nodes
|
||||||
|
(label text, colours from `Palette`). Do **not** create nodes in `_ready()`.
|
||||||
|
- **Data-driven repeats = fixed authored slots, bound at runtime.** Author enough
|
||||||
|
turn-order tokens / consumable slots / etc. in the editor; the script fills the
|
||||||
|
first N from state and hides the rest. Previewable now; systems write them later.
|
||||||
|
- **Scene-root sizing.** Give the root a fixed size via offsets (e.g. 1920×1080)
|
||||||
|
for editor preview, but **no full-rect anchors on the root** — a root Control run
|
||||||
|
via F6 collapses to (0,0) under full-rect anchors. Keep `size = get_viewport_rect().size`
|
||||||
|
on `_ready` + a `size_changed` connection (the M3-a collapse lesson).
|
||||||
|
- **Button styling → Button-base variations only.** Never put a PanelContainer
|
||||||
|
variation (`DarkPanel`, `ParchmentCard`) on a `Button`; it has no `normal/hover/
|
||||||
|
pressed` styleboxes so the button renders unskinned. Use the Button-base
|
||||||
|
variations (`PrimaryCTA`, `Tab`, `TabActive`, `Chip`, `DockButton`, `ParchmentButton`).
|
||||||
|
- **`RichTextLabel` prose** reads its dark serif ink from the theme's base
|
||||||
|
`RichTextLabel` style (it does *not* inherit `default_font`/`default_color`).
|
||||||
|
Just add the `RichTextLabel` — don't override its colour per-node.
|
||||||
|
- **Theme is generated.** All colours live in `palette.gd`; `game_theme.tres` is
|
||||||
|
built by `scripts/theme/build_game_theme.gd` (source of truth). To add/adjust a
|
||||||
|
variation, edit the builder + `theme_keys.gd`, then regenerate:
|
||||||
|
`godot --headless -s res://scripts/theme/build_game_theme.gd`. Never hand-edit
|
||||||
|
the `.tres` or put a hex literal in a script or `.tscn`.
|
||||||
|
- **Wiring the DM loop / injectable services.** Follow the shell: a `service`
|
||||||
|
var settable *between* `instantiate()` and `add_child()` lets tests inject a
|
||||||
|
`FakeTransport`-backed `DmService` so on-load calls stay hermetic; `_ready`
|
||||||
|
builds the real `HttpTransport`-backed one when none was injected.
|
||||||
|
- **Visual gate.** Headless GUT proves structure (nodes mount, sizes non-zero,
|
||||||
|
bindings run); it cannot prove rendering. Every screen needs a **human F6
|
||||||
|
eyeball** before it's done. Note shader surfaces (`DarkBay`, vignette) render
|
||||||
|
their texture only on an F6 run, not in the editor.
|
||||||
|
|
||||||
## Narrate live smoke (M2 client HTTP loop)
|
## Narrate live smoke (M2 client HTTP loop)
|
||||||
|
|
||||||
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
The `narrate_harness` scene is a throwaway proof of the client → `/dm/narrate`
|
||||||
|
|||||||
300
client/scenes/shell/MainWindowShell.tscn
Normal file
300
client/scenes/shell/MainWindowShell.tscn
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
[gd_scene load_steps=6 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/theme/surfaces/DarkBay.tscn" id="3"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/shell/SystemDock.tscn" id="4"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/shell/NarrationBook.tscn" id="5"]
|
||||||
|
|
||||||
|
[node name="MainWindowShell" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 1920.0
|
||||||
|
offset_bottom = 1080.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Split" type="HBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
theme_override_constants/separation = 0
|
||||||
|
|
||||||
|
[node name="World" type="Control" parent="Split"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
clip_contents = true
|
||||||
|
|
||||||
|
[node name="Bay" parent="Split/World" instance=ExtResource("3")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
|
||||||
|
[node name="Caption" type="Label" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 8
|
||||||
|
anchor_left = 0.5
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 0.5
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "ISOMETRIC WORLD VIEW"
|
||||||
|
|
||||||
|
[node name="TurnRail" type="PanelContainer" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_left = 28.0
|
||||||
|
offset_top = 28.0
|
||||||
|
offset_right = 300.0
|
||||||
|
offset_bottom = 128.0
|
||||||
|
theme_type_variation = &"DarkPanel"
|
||||||
|
|
||||||
|
[node name="Col" type="VBoxContainer" parent="Split/World/TurnRail"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="Title" type="Label" parent="Split/World/TurnRail/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "TURN ORDER"
|
||||||
|
|
||||||
|
[node name="Tokens" type="HBoxContainer" parent="Split/World/TurnRail/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 12
|
||||||
|
|
||||||
|
[node name="T0" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "EL"
|
||||||
|
|
||||||
|
[node name="T1" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "DW"
|
||||||
|
|
||||||
|
[node name="T2" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "HU"
|
||||||
|
|
||||||
|
[node name="T3" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "✕"
|
||||||
|
|
||||||
|
[node name="T4" type="Label" parent="Split/World/TurnRail/Col/Tokens"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "—"
|
||||||
|
|
||||||
|
[node name="Minimap" type="PanelContainer" parent="Split/World"]
|
||||||
|
custom_minimum_size = Vector2(200, 200)
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -228.0
|
||||||
|
offset_top = 28.0
|
||||||
|
offset_right = -28.0
|
||||||
|
offset_bottom = 228.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
theme_type_variation = &"DarkPanel"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/Minimap"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 1
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "MINIMAP"
|
||||||
|
|
||||||
|
[node name="SystemDock" parent="Split/World" instance=ExtResource("4")]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 6
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -228.0
|
||||||
|
offset_top = -210.0
|
||||||
|
offset_right = -8.0
|
||||||
|
offset_bottom = 210.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 2
|
||||||
|
|
||||||
|
[node name="CommandBar" type="HBoxContainer" parent="Split/World"]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 12
|
||||||
|
anchor_top = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
offset_left = 28.0
|
||||||
|
offset_top = -150.0
|
||||||
|
offset_right = -28.0
|
||||||
|
grow_vertical = 0
|
||||||
|
theme_override_constants/separation = 20
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="Vitals" type="VBoxContainer" parent="Split/World/CommandBar"]
|
||||||
|
custom_minimum_size = Vector2(120, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 8
|
||||||
|
theme_override_constants/separation = 8
|
||||||
|
|
||||||
|
[node name="HP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "HP 42 / 60"
|
||||||
|
|
||||||
|
[node name="MP" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "MP 18 / 30"
|
||||||
|
|
||||||
|
[node name="Gold" type="Label" parent="Split/World/CommandBar/Vitals"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "◈ 1240 gp"
|
||||||
|
|
||||||
|
[node name="Consumables" type="HBoxContainer" parent="Split/World/CommandBar"]
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 8
|
||||||
|
theme_override_constants/separation = 9
|
||||||
|
alignment = 1
|
||||||
|
|
||||||
|
[node name="Slot1" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot1"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Salve"
|
||||||
|
|
||||||
|
[node name="Slot2" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot2"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Draught"
|
||||||
|
|
||||||
|
[node name="Slot3" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot3"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Bomb"
|
||||||
|
|
||||||
|
[node name="Slot4" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot4"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Antidote"
|
||||||
|
|
||||||
|
[node name="Slot5" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot5"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Ration"
|
||||||
|
|
||||||
|
[node name="Slot6" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTile"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot6"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "Torch"
|
||||||
|
|
||||||
|
[node name="Slot7" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTileEmpty"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot7"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
|
||||||
|
[node name="Slot8" type="PanelContainer" parent="Split/World/CommandBar/Consumables"]
|
||||||
|
custom_minimum_size = Vector2(93, 62)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"ItemTileEmpty"
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="Split/World/CommandBar/Consumables/Slot8"]
|
||||||
|
layout_mode = 2
|
||||||
|
horizontal_alignment = 1
|
||||||
|
vertical_alignment = 2
|
||||||
|
clip_text = true
|
||||||
|
theme_override_font_sizes/font_size = 11
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
|
||||||
|
[node name="EndTurn" type="Button" parent="Split/World/CommandBar"]
|
||||||
|
custom_minimum_size = Vector2(140, 60)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 4
|
||||||
|
theme_type_variation = &"PrimaryCTA"
|
||||||
|
text = "END TURN"
|
||||||
|
|
||||||
|
[node name="BookPanel" type="PanelContainer" parent="Split"]
|
||||||
|
custom_minimum_size = Vector2(740, 0)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"ParchmentCard"
|
||||||
|
|
||||||
|
[node name="Book" parent="Split/BookPanel" instance=ExtResource("5")]
|
||||||
|
layout_mode = 2
|
||||||
85
client/scenes/shell/NarrationBook.tscn
Normal file
85
client/scenes/shell/NarrationBook.tscn
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
|
||||||
|
[node name="NarrationBook" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 740.0
|
||||||
|
offset_bottom = 1080.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Margin" type="MarginContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
theme_override_constants/margin_left = 40
|
||||||
|
theme_override_constants/margin_top = 34
|
||||||
|
theme_override_constants/margin_right = 40
|
||||||
|
theme_override_constants/margin_bottom = 34
|
||||||
|
|
||||||
|
[node name="Col" type="VBoxContainer" parent="Margin"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_override_constants/separation = 18
|
||||||
|
|
||||||
|
[node name="Header" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "ROUND 4 · THE LOWER WARD"
|
||||||
|
|
||||||
|
[node name="Prose" type="RichTextLabel" parent="Margin/Col"]
|
||||||
|
custom_minimum_size = Vector2(660, 320)
|
||||||
|
layout_mode = 2
|
||||||
|
size_flags_vertical = 3
|
||||||
|
bbcode_enabled = true
|
||||||
|
text = "The alley stinks of piss and rot. Rain beads on the cobbles and runs black into the gutter."
|
||||||
|
fit_content = true
|
||||||
|
|
||||||
|
[node name="DegradedTag" type="Label" parent="Margin/Col"]
|
||||||
|
visible = false
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "THE MASTER'S VOICE COMES THIN"
|
||||||
|
|
||||||
|
[node name="Prompt" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Accent"
|
||||||
|
text = "▸ Your turn. What do you do?"
|
||||||
|
|
||||||
|
[node name="Choice1" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "1 · Pay the fence double and keep your hand where he can see it"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="Choice2" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "2 · Draw your blade — he's closer than his guards"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="Choice3" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
disabled = true
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "3 · Lie — tell him the rest is coming with your dwarf"
|
||||||
|
alignment = 0
|
||||||
|
|
||||||
|
[node name="FreeText" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
|
text = "⌨ …or describe your own action"
|
||||||
|
|
||||||
|
[node name="Refire" type="Button" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"ParchmentButton"
|
||||||
|
text = "▸ The Master continues…"
|
||||||
|
|
||||||
|
[node name="Status" type="Label" parent="Margin/Col"]
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"Mono"
|
||||||
86
client/scenes/shell/SystemDock.tscn
Normal file
86
client/scenes/shell/SystemDock.tscn
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
|
||||||
|
[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="2"]
|
||||||
|
|
||||||
|
[node name="SystemDock" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_right = 220.0
|
||||||
|
offset_bottom = 420.0
|
||||||
|
theme = ExtResource("2")
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="Menu" type="VBoxContainer" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -216.0
|
||||||
|
offset_right = -36.0
|
||||||
|
offset_bottom = 316.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
theme_override_constants/separation = 10
|
||||||
|
|
||||||
|
[node name="Inventory" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "I Inventory"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"inventory"
|
||||||
|
|
||||||
|
[node name="Character" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "C Character"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"character"
|
||||||
|
|
||||||
|
[node name="QuestLog" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "J Quest Log"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"quest_log"
|
||||||
|
|
||||||
|
[node name="Spellbook" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "K Spellbook"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"spellbook"
|
||||||
|
|
||||||
|
[node name="WorldMap" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "M World Map"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"world_map"
|
||||||
|
|
||||||
|
[node name="Party" type="Button" parent="Menu"]
|
||||||
|
custom_minimum_size = Vector2(0, 42)
|
||||||
|
layout_mode = 2
|
||||||
|
theme_type_variation = &"DockButton"
|
||||||
|
text = "P Party"
|
||||||
|
alignment = 0
|
||||||
|
metadata/screen_id = &"party"
|
||||||
|
|
||||||
|
[node name="Handle" type="Button" parent="."]
|
||||||
|
custom_minimum_size = Vector2(26, 104)
|
||||||
|
layout_mode = 1
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_top = 0.5
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 0.5
|
||||||
|
offset_left = -26.0
|
||||||
|
offset_top = -52.0
|
||||||
|
offset_right = 0.0
|
||||||
|
offset_bottom = 52.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
grow_vertical = 2
|
||||||
|
theme_type_variation = &"TabActive"
|
||||||
|
text = "›"
|
||||||
@@ -16,6 +16,7 @@ extends SceneTree
|
|||||||
|
|
||||||
const OUT := "res://assets/theme/game_theme.tres"
|
const OUT := "res://assets/theme/game_theme.tres"
|
||||||
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
|
const SERIF := "res://assets/theme/fonts/EBGaramond-VariableFont_wght.ttf"
|
||||||
|
const SERIF_ITALIC := "res://assets/theme/fonts/EBGaramond-Italic-VariableFont_wght.ttf"
|
||||||
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
|
const ACCENT := "res://assets/theme/fonts/ArchitectsDaughter-Regular.ttf"
|
||||||
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
const MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
||||||
|
|
||||||
@@ -33,8 +34,11 @@ func _init() -> void:
|
|||||||
static func build_theme() -> Theme:
|
static func build_theme() -> Theme:
|
||||||
var theme := Theme.new()
|
var theme := Theme.new()
|
||||||
_fonts(theme)
|
_fonts(theme)
|
||||||
|
_rich_text(theme)
|
||||||
_primary_cta(theme)
|
_primary_cta(theme)
|
||||||
_tabs(theme)
|
_tabs(theme)
|
||||||
|
_dock_button(theme)
|
||||||
|
_parchment_button(theme)
|
||||||
_chip(theme)
|
_chip(theme)
|
||||||
_tiles(theme)
|
_tiles(theme)
|
||||||
_cards_and_panels(theme)
|
_cards_and_panels(theme)
|
||||||
@@ -76,6 +80,18 @@ static func _fonts(theme: Theme) -> void:
|
|||||||
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
|
theme.set_color(&"font_color", ThemeKeys.MONO, Palette.MUTED_MONO)
|
||||||
|
|
||||||
|
|
||||||
|
static func _rich_text(theme: Theme) -> void:
|
||||||
|
# Base RichTextLabel styling — the narrative "prose" voice (mock README §Typography:
|
||||||
|
# Georgia/serif for DM/flavor body, italic for emphasis). RichTextLabel does NOT
|
||||||
|
# inherit default_font/default_color, so without this its text renders in the engine
|
||||||
|
# default (near-white) and is unreadable on parchment. Every parchment prose surface
|
||||||
|
# (the narration book, later quest briefs/dialogue) reads dark serif ink from here.
|
||||||
|
theme.set_font(&"normal_font", "RichTextLabel", load(SERIF))
|
||||||
|
theme.set_font(&"italics_font", "RichTextLabel", load(SERIF_ITALIC))
|
||||||
|
theme.set_font_size(&"normal_font_size", "RichTextLabel", 20)
|
||||||
|
theme.set_color(&"default_color", "RichTextLabel", Palette.INK_BODY)
|
||||||
|
|
||||||
|
|
||||||
static func _primary_cta(theme: Theme) -> void:
|
static func _primary_cta(theme: Theme) -> void:
|
||||||
var k := ThemeKeys.PRIMARY_CTA
|
var k := ThemeKeys.PRIMARY_CTA
|
||||||
theme.set_type_variation(k, "Button")
|
theme.set_type_variation(k, "Button")
|
||||||
@@ -106,6 +122,35 @@ static func _tabs(theme: Theme) -> void:
|
|||||||
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
|
theme.set_color(&"font_color", tab_active, Palette.CREAM_BRIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
static func _dock_button(theme: Theme) -> void:
|
||||||
|
# Dark nav button for the slide-in system dock (mock 2a). Button-base so
|
||||||
|
# normal/hover/pressed styleboxes actually apply (a PanelContainer variation's
|
||||||
|
# lone `panel` stylebox does not skin a Button). Colours are dock-dark.
|
||||||
|
var k := ThemeKeys.DOCK_BUTTON
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.DARK_PANEL_70, Palette.BORDER_1, 1, 8))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.DARK_PANEL_85, Palette.GOLD, 1, 8))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.DARK_PANEL_90, Palette.GOLD, 1, 8))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", k, Palette.CREAM_SECONDARY)
|
||||||
|
|
||||||
|
|
||||||
|
static func _parchment_button(theme: Theme) -> void:
|
||||||
|
# Clickable parchment row/card for the narration book (mock 2a response
|
||||||
|
# choices + the refire affordance). Button-base so normal/hover/pressed/
|
||||||
|
# disabled styleboxes actually apply (a PanelContainer variation's lone
|
||||||
|
# `panel` stylebox does not skin a Button). Colours are the mock's choice-card.
|
||||||
|
var k := ThemeKeys.PARCHMENT_BUTTON
|
||||||
|
theme.set_type_variation(k, "Button")
|
||||||
|
theme.set_stylebox(&"normal", k, _flat(Palette.PARCHMENT_CARD, Palette.PARCHMENT_BORDER_3, 1, 8))
|
||||||
|
theme.set_stylebox(&"hover", k, _flat(Palette.PARCHMENT_INSET, Palette.INK_LABEL, 1, 8))
|
||||||
|
theme.set_stylebox(&"pressed", k, _flat(Palette.PARCHMENT_INSET_2, Palette.INK_LABEL, 1, 8))
|
||||||
|
theme.set_stylebox(&"disabled", k, _flat(Palette.PARCHMENT_INSET_2, Palette.PARCHMENT_BORDER_3, 1, 8))
|
||||||
|
theme.set_stylebox(&"focus", k, StyleBoxEmpty.new())
|
||||||
|
theme.set_color(&"font_color", k, Palette.INK_BODY)
|
||||||
|
theme.set_color(&"font_disabled_color", k, Palette.INK_MUTED)
|
||||||
|
|
||||||
|
|
||||||
static func _chip(theme: Theme) -> void:
|
static func _chip(theme: Theme) -> void:
|
||||||
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
|
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
|
||||||
var k := ThemeKeys.CHIP
|
var k := ThemeKeys.CHIP
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const PRIMARY_CTA := &"PrimaryCTA"
|
|||||||
const TAB := &"Tab"
|
const TAB := &"Tab"
|
||||||
const TAB_ACTIVE := &"TabActive"
|
const TAB_ACTIVE := &"TabActive"
|
||||||
const CHIP := &"Chip"
|
const CHIP := &"Chip"
|
||||||
|
const DOCK_BUTTON := &"DockButton"
|
||||||
|
const PARCHMENT_BUTTON := &"ParchmentButton"
|
||||||
const ITEM_TILE := &"ItemTile"
|
const ITEM_TILE := &"ItemTile"
|
||||||
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
||||||
const PARCHMENT_CARD := &"ParchmentCard"
|
const PARCHMENT_CARD := &"ParchmentCard"
|
||||||
@@ -28,6 +30,8 @@ const ALL := {
|
|||||||
TAB: "Button",
|
TAB: "Button",
|
||||||
TAB_ACTIVE: "Button",
|
TAB_ACTIVE: "Button",
|
||||||
CHIP: "Button",
|
CHIP: "Button",
|
||||||
|
DOCK_BUTTON: "Button",
|
||||||
|
PARCHMENT_BUTTON: "Button",
|
||||||
ITEM_TILE: "PanelContainer",
|
ITEM_TILE: "PanelContainer",
|
||||||
ITEM_TILE_EMPTY: "PanelContainer",
|
ITEM_TILE_EMPTY: "PanelContainer",
|
||||||
PARCHMENT_CARD: "PanelContainer",
|
PARCHMENT_CARD: "PanelContainer",
|
||||||
|
|||||||
134
client/scripts/ui/shell/main_window_shell.gd
Normal file
134
client/scripts/ui/shell/main_window_shell.gd
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
class_name MainWindowShell
|
||||||
|
extends Control
|
||||||
|
## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel
|
||||||
|
## tabletop split: a dark isometric world slot (turn rail, minimap, system dock,
|
||||||
|
## command bar) + the permanent DM narration book. §2: the shell owns HUD state
|
||||||
|
## (ShellState) and consumes DM text (never owns narrative truth). It reuses the
|
||||||
|
## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented.
|
||||||
|
##
|
||||||
|
## Layout is authored in MainWindowShell.tscn (and its SystemDock/NarrationBook
|
||||||
|
## sub-scenes) — open it in the editor to arrange it. This script binds the seed
|
||||||
|
## ShellState into the authored nodes, wires the DM loop, and keeps the runtime
|
||||||
|
## viewport-fit. The turn-order tokens and consumable slots are a FIXED set of
|
||||||
|
## authored nodes bound from state (extra slots hide); combat/inventory become the
|
||||||
|
## writers later.
|
||||||
|
|
||||||
|
# HttpTransport carries no class_name (unlike DmService/FallbackLibrary) — every
|
||||||
|
# other call site (narrate_harness.gd, test_net_primitives.gd) reaches it via
|
||||||
|
# preload, so this mirrors the existing pattern rather than inventing a new one.
|
||||||
|
const HttpTransport = preload("res://scripts/net/http_transport.gd")
|
||||||
|
|
||||||
|
var service: DmService = null # injection seam; real HttpTransport built if null
|
||||||
|
|
||||||
|
var _state: ShellState
|
||||||
|
var _log: CanonLog
|
||||||
|
var _http: HTTPRequest
|
||||||
|
|
||||||
|
@onready var book: NarrationBook = $Split/BookPanel/Book
|
||||||
|
@onready var dock: SystemDock = $Split/World/SystemDock
|
||||||
|
@onready var _tokens: HBoxContainer = $Split/World/TurnRail/Col/Tokens
|
||||||
|
@onready var _hp: Label = $Split/World/CommandBar/Vitals/HP
|
||||||
|
@onready var _mp: Label = $Split/World/CommandBar/Vitals/MP
|
||||||
|
@onready var _gold: Label = $Split/World/CommandBar/Vitals/Gold
|
||||||
|
@onready var _consumables: HBoxContainer = $Split/World/CommandBar/Consumables
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The theme is assigned on the scene root in the editor (so it previews). Keep
|
||||||
|
# the runtime viewport-fit: a root Control run via F6 is not sized by full-rect
|
||||||
|
# anchors — the .tscn root uses a fixed size + no full-rect preset (M3-a bug).
|
||||||
|
_fit_to_viewport()
|
||||||
|
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||||
|
|
||||||
|
_state = ShellState.seed()
|
||||||
|
_log = _build_seed_log()
|
||||||
|
_bind_state()
|
||||||
|
|
||||||
|
if service == null:
|
||||||
|
_http = HTTPRequest.new()
|
||||||
|
add_child(_http)
|
||||||
|
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||||
|
|
||||||
|
book.setup(service, _log, _state)
|
||||||
|
dock.setup(_state)
|
||||||
|
dock.screen_requested.connect(_on_screen_requested)
|
||||||
|
book.narrate() # initial narration; async, fire-and-forget
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_viewport() -> void:
|
||||||
|
size = get_viewport_rect().size
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_state() -> void:
|
||||||
|
_bind_turn_rail()
|
||||||
|
_bind_vitals()
|
||||||
|
_bind_consumables()
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_turn_rail() -> void:
|
||||||
|
# Fill the authored token labels from the turn order; hide any spare slots.
|
||||||
|
var tokens := _tokens.get_children()
|
||||||
|
for i in range(tokens.size()):
|
||||||
|
var label: Label = tokens[i]
|
||||||
|
if i < _state.turn_order.size():
|
||||||
|
var e: TurnEntry = _state.turn_order[i]
|
||||||
|
label.text = "✕" if e.is_downed else e.initials
|
||||||
|
label.add_theme_color_override("font_color", _side_color(e))
|
||||||
|
label.visible = true
|
||||||
|
else:
|
||||||
|
label.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _side_color(e: TurnEntry) -> Color:
|
||||||
|
if e.is_downed:
|
||||||
|
return Palette.BLOOD
|
||||||
|
if e.is_active:
|
||||||
|
return Palette.GOLD
|
||||||
|
match e.side:
|
||||||
|
&"enemy": return Palette.BLOOD_BRIGHT
|
||||||
|
&"ally": return Palette.STEEL
|
||||||
|
_: return Palette.CREAM
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_vitals() -> void:
|
||||||
|
_hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
|
||||||
|
_mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
|
||||||
|
_gold.text = "◈ %d gp" % _state.vitals["gold"]
|
||||||
|
_gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
func _bind_consumables() -> void:
|
||||||
|
# The 8 slots are authored; bind labels + fill/empty variation, hide spares.
|
||||||
|
var slots := _consumables.get_children()
|
||||||
|
for i in range(slots.size()):
|
||||||
|
var slot: PanelContainer = slots[i]
|
||||||
|
var label: Label = slot.get_node("Label")
|
||||||
|
if i < _state.consumables.size():
|
||||||
|
var c: Dictionary = _state.consumables[i]
|
||||||
|
label.text = c["label"]
|
||||||
|
slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY
|
||||||
|
slot.visible = true
|
||||||
|
else:
|
||||||
|
slot.visible = false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_screen_requested(_id: StringName) -> void:
|
||||||
|
pass # routing seam — the dock's screens land in later milestones (§17)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_seed_log() -> CanonLog:
|
||||||
|
# Code owns the canon log (§11). Hand-built until character creation (M4) and
|
||||||
|
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
|
||||||
|
# number is rendered anywhere (§7); the descriptor is prose-only.
|
||||||
|
var log := CanonLog.new()
|
||||||
|
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
|
||||||
|
log.set_location("lower_ward", "the Lower Ward")
|
||||||
|
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||||
|
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||||
|
log.push_event("Followed the beastfolk fence into the Lower Ward after dark")
|
||||||
|
log.push_event("The sewer bite on Vexcca's left arm has begun to turn")
|
||||||
|
log.add_fact("the fence deals out of the Lower Ward")
|
||||||
|
log.add_fact("Vexcca carries an infected bite on the left arm")
|
||||||
|
log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence"))
|
||||||
|
log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4)
|
||||||
|
return log
|
||||||
1
client/scripts/ui/shell/main_window_shell.gd.uid
Normal file
1
client/scripts/ui/shell/main_window_shell.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bobihkpvsax2u
|
||||||
61
client/scripts/ui/shell/narration_book.gd
Normal file
61
client/scripts/ui/shell/narration_book.gd
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
class_name NarrationBook
|
||||||
|
extends Control
|
||||||
|
## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes
|
||||||
|
## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are
|
||||||
|
## written back to the canon log (§11). Prose-only this milestone: the numbered
|
||||||
|
## response choices and the "describe your own action" line are faithful but inert
|
||||||
|
## (no action system yet); one in-world refire affordance re-runs narration.
|
||||||
|
##
|
||||||
|
## Layout is authored in NarrationBook.tscn (editor-previewable). This script only
|
||||||
|
## binds the header, drives the narrate loop, and swaps prose text.
|
||||||
|
|
||||||
|
var last_degraded: bool = false
|
||||||
|
|
||||||
|
var _service: DmService
|
||||||
|
var _log: CanonLog
|
||||||
|
var _state: ShellState
|
||||||
|
var _indicator: ConsideringIndicator
|
||||||
|
|
||||||
|
@onready var _header: Label = $Margin/Col/Header
|
||||||
|
@onready var _prose: RichTextLabel = $Margin/Col/Prose
|
||||||
|
@onready var _degraded_tag: Label = $Margin/Col/DegradedTag
|
||||||
|
@onready var _refire: Button = $Margin/Col/Refire
|
||||||
|
@onready var _status: Label = $Margin/Col/Status
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The considering indicator is a non-visual Node (a Timer + label driver), so
|
||||||
|
# it stays code-created rather than authored in the scene.
|
||||||
|
_indicator = ConsideringIndicator.new()
|
||||||
|
add_child(_indicator)
|
||||||
|
_refire.pressed.connect(_on_refire_pressed)
|
||||||
|
|
||||||
|
|
||||||
|
func setup(service: DmService, log: CanonLog, state: ShellState) -> void:
|
||||||
|
_service = service
|
||||||
|
_log = log
|
||||||
|
_state = state
|
||||||
|
_header.text = "%s · %s" % [state.round_label, state.location_label]
|
||||||
|
|
||||||
|
|
||||||
|
func narrate() -> void:
|
||||||
|
if _service == null or _log == null:
|
||||||
|
return
|
||||||
|
_refire.disabled = true
|
||||||
|
_indicator.start(_status)
|
||||||
|
var result: NarrateResult = await _service.narrate(_log)
|
||||||
|
_indicator.stop()
|
||||||
|
show_prose(result)
|
||||||
|
for f in result.facts:
|
||||||
|
_log.add_fact(f) # §11: code applies the state write, explicitly
|
||||||
|
_refire.disabled = false
|
||||||
|
|
||||||
|
|
||||||
|
func show_prose(result: NarrateResult) -> void:
|
||||||
|
_prose.text = result.display_text
|
||||||
|
last_degraded = result.degraded
|
||||||
|
_degraded_tag.visible = result.degraded
|
||||||
|
|
||||||
|
|
||||||
|
func _on_refire_pressed() -> void:
|
||||||
|
narrate()
|
||||||
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
1
client/scripts/ui/shell/narration_book.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://blmbc06xpea3x
|
||||||
36
client/scripts/ui/shell/shell_state.gd
Normal file
36
client/scripts/ui/shell/shell_state.gd
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
class_name ShellState
|
||||||
|
extends RefCounted
|
||||||
|
## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock
|
||||||
|
## 2a; later systems (combat vitals, inventory consumables) become the writers.
|
||||||
|
## No Luck/LCK value lives here — it is never surfaced (§7).
|
||||||
|
|
||||||
|
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
|
||||||
|
var turn_order: Array[TurnEntry] = []
|
||||||
|
var consumables: Array = []
|
||||||
|
var round_label: String = ""
|
||||||
|
var location_label: String = ""
|
||||||
|
var dock_open: bool = true
|
||||||
|
|
||||||
|
|
||||||
|
func toggle_dock() -> bool:
|
||||||
|
dock_open = not dock_open
|
||||||
|
return dock_open
|
||||||
|
|
||||||
|
|
||||||
|
static func seed() -> ShellState:
|
||||||
|
var s := ShellState.new()
|
||||||
|
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
|
||||||
|
s.turn_order = [
|
||||||
|
TurnEntry.new("EL", 17, &"you", true, false),
|
||||||
|
TurnEntry.new("DW", 12, &"ally"),
|
||||||
|
TurnEntry.new("HU", 9, &"ally"),
|
||||||
|
TurnEntry.new("GK", 0, &"enemy", false, true),
|
||||||
|
]
|
||||||
|
var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""]
|
||||||
|
s.consumables = []
|
||||||
|
for i in range(labels.size()):
|
||||||
|
s.consumables.append({"hotkey": i + 1, "label": labels[i]})
|
||||||
|
s.round_label = "ROUND 4"
|
||||||
|
s.location_label = "THE LOWER WARD"
|
||||||
|
s.dock_open = true
|
||||||
|
return s
|
||||||
1
client/scripts/ui/shell/shell_state.gd.uid
Normal file
1
client/scripts/ui/shell/shell_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://f8vix48wmisv
|
||||||
61
client/scripts/ui/shell/system_dock.gd
Normal file
61
client/scripts/ui/shell/system_dock.gd
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
class_name SystemDock
|
||||||
|
extends Control
|
||||||
|
## The slide-in system dock (mock 2a): a gold handle toggles a column of screen
|
||||||
|
## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons
|
||||||
|
## are the routing seam — they emit screen_requested; the screens they open land
|
||||||
|
## in later milestones, so the signal is currently a no-op for the shell.
|
||||||
|
##
|
||||||
|
## Layout is authored in SystemDock.tscn (editor-previewable). This script only
|
||||||
|
## wires the buttons' signals, drives the slide animation, and toggles state. Each
|
||||||
|
## dock button carries a `screen_id` StringName in its scene metadata; add/reorder
|
||||||
|
## buttons in the editor and the wiring follows.
|
||||||
|
|
||||||
|
signal screen_requested(id: StringName)
|
||||||
|
|
||||||
|
const SLIDE_SECONDS := 0.32
|
||||||
|
const HIDDEN_SHIFT := 230.0
|
||||||
|
|
||||||
|
var _screen_buttons: Dictionary = {}
|
||||||
|
var _state: ShellState
|
||||||
|
var _menu_open_pos: Vector2
|
||||||
|
|
||||||
|
@onready var _menu: VBoxContainer = $Menu
|
||||||
|
@onready var _handle: Button = $Handle
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# The buttons live in the scene; harvest each one's authored screen_id so the
|
||||||
|
# routing seam follows editor edits (add/remove/reorder needs no code change).
|
||||||
|
for child in _menu.get_children():
|
||||||
|
if child is Button and child.has_meta("screen_id"):
|
||||||
|
var id: StringName = child.get_meta("screen_id")
|
||||||
|
child.pressed.connect(func(): screen_requested.emit(id))
|
||||||
|
_screen_buttons[id] = child
|
||||||
|
_handle.pressed.connect(_on_handle_pressed)
|
||||||
|
# Capture the authored open position so the slide is relative to wherever the
|
||||||
|
# menu is placed in the editor — moving it in the editor just works.
|
||||||
|
_menu_open_pos = _menu.position
|
||||||
|
|
||||||
|
|
||||||
|
func setup(state: ShellState) -> void:
|
||||||
|
_state = state
|
||||||
|
_apply_open(_state.dock_open)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_handle_pressed() -> void:
|
||||||
|
if _state == null:
|
||||||
|
return
|
||||||
|
_apply_open(_state.toggle_dock())
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_open(open: bool) -> void:
|
||||||
|
_handle.text = "›" if open else "‹"
|
||||||
|
var target := _menu_open_pos if open else _menu_open_pos + Vector2(HIDDEN_SHIFT, 0.0)
|
||||||
|
var alpha := 1.0 if open else 0.0
|
||||||
|
if not is_inside_tree():
|
||||||
|
_menu.position = target
|
||||||
|
_menu.modulate.a = alpha
|
||||||
|
return
|
||||||
|
var tween := create_tween().set_parallel(true)
|
||||||
|
tween.tween_property(_menu, "position", target, SLIDE_SECONDS)
|
||||||
|
tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS)
|
||||||
1
client/scripts/ui/shell/system_dock.gd.uid
Normal file
1
client/scripts/ui/shell/system_dock.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dvbg24rvye3qw
|
||||||
20
client/scripts/ui/shell/turn_entry.gd
Normal file
20
client/scripts/ui/shell/turn_entry.gd
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
class_name TurnEntry
|
||||||
|
extends RefCounted
|
||||||
|
## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the
|
||||||
|
## combat turn manager (M5) becomes its writer. `side` colours the rail:
|
||||||
|
## &"you" gold, &"ally" teal-muted, &"enemy" blood.
|
||||||
|
|
||||||
|
var initials: String
|
||||||
|
var initiative: int
|
||||||
|
var side: StringName
|
||||||
|
var is_active: bool
|
||||||
|
var is_downed: bool
|
||||||
|
|
||||||
|
|
||||||
|
func _init(p_initials: String, p_initiative: int, p_side: StringName,
|
||||||
|
p_is_active := false, p_is_downed := false) -> void:
|
||||||
|
initials = p_initials
|
||||||
|
initiative = p_initiative
|
||||||
|
side = p_side
|
||||||
|
is_active = p_is_active
|
||||||
|
is_downed = p_is_downed
|
||||||
1
client/scripts/ui/shell/turn_entry.gd.uid
Normal file
1
client/scripts/ui/shell/turn_entry.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://toetic2iu7b8
|
||||||
37
client/tests/unit/test_main_window_shell.gd
Normal file
37
client/tests/unit/test_main_window_shell.gd
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
|
||||||
|
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||||
|
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _shell() -> MainWindowShell:
|
||||||
|
# Inject a fake service BEFORE _ready (add_child) so the initial narrate does
|
||||||
|
# not touch the network — instantiate() does not run _ready(); add_child does.
|
||||||
|
var node = load(SCENE).instantiate()
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
|
||||||
|
node.service = DmService.new(transport, FallbackLibrary.new())
|
||||||
|
add_child_autofree(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_applies_theme_and_fits_viewport():
|
||||||
|
var packed = load(SCENE)
|
||||||
|
assert_true(packed is PackedScene)
|
||||||
|
var node := _shell()
|
||||||
|
assert_true(node is Control)
|
||||||
|
assert_true(node.theme is Theme, "shell applies game_theme")
|
||||||
|
assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)")
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_mounts_both_panels():
|
||||||
|
var node := _shell()
|
||||||
|
assert_not_null(node.book, "narration book mounted")
|
||||||
|
assert_not_null(node.dock, "system dock mounted")
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_builds_a_code_owned_seed_log():
|
||||||
|
var node := _shell()
|
||||||
|
assert_not_null(node._log, "a canon log exists to post (§11)")
|
||||||
|
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
|
||||||
1
client/tests/unit/test_main_window_shell.gd.uid
Normal file
1
client/tests/unit/test_main_window_shell.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dbd4cq1awo4su
|
||||||
37
client/tests/unit/test_narration_book.gd
Normal file
37
client/tests/unit/test_narration_book.gd
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/NarrationBook.tscn"
|
||||||
|
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||||
|
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _book_with(response) -> NarrationBook:
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(response)
|
||||||
|
var service := DmService.new(transport, FallbackLibrary.new())
|
||||||
|
var book = load(SCENE).instantiate()
|
||||||
|
add_child_autofree(book) # _ready() builds nodes + the considering indicator
|
||||||
|
book.setup(service, CanonLog.new(), ShellState.seed())
|
||||||
|
return book
|
||||||
|
|
||||||
|
|
||||||
|
func test_narrate_shows_prose_and_harvests_facts():
|
||||||
|
var book := _book_with(DmResponse.ok(200,
|
||||||
|
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
|
||||||
|
var log := CanonLog.new()
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(DmResponse.ok(200,
|
||||||
|
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
|
||||||
|
book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed())
|
||||||
|
await book.narrate()
|
||||||
|
assert_string_contains(book._prose.text, "The rain runs black into the gutter.")
|
||||||
|
assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose")
|
||||||
|
assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log")
|
||||||
|
assert_false(book.last_degraded)
|
||||||
|
|
||||||
|
|
||||||
|
func test_degraded_response_shows_fallback_and_sets_flag():
|
||||||
|
var book := _book_with(DmResponse.failed("proxy down"))
|
||||||
|
await book.narrate()
|
||||||
|
assert_true(book.last_degraded, "a failed call degrades")
|
||||||
|
assert_ne(book._prose.text, "", "the fallback line is still shown (§13)")
|
||||||
1
client/tests/unit/test_narration_book.gd.uid
Normal file
1
client/tests/unit/test_narration_book.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dj6ly2ljf3ai4
|
||||||
44
client/tests/unit/test_shell_state.gd
Normal file
44
client/tests/unit/test_shell_state.gd
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func test_turn_entry_holds_fields():
|
||||||
|
var e := TurnEntry.new("DW", 12, &"ally")
|
||||||
|
assert_eq(e.initials, "DW")
|
||||||
|
assert_eq(e.initiative, 12)
|
||||||
|
assert_eq(e.side, &"ally")
|
||||||
|
assert_false(e.is_active)
|
||||||
|
assert_false(e.is_downed)
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_vitals():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.vitals["hp"], 42)
|
||||||
|
assert_eq(s.vitals["hp_max"], 60)
|
||||||
|
assert_eq(s.vitals["mp"], 18)
|
||||||
|
assert_eq(s.vitals["mp_max"], 30)
|
||||||
|
assert_eq(s.vitals["gold"], 1240)
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_turn_order():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.turn_order.size(), 4)
|
||||||
|
assert_eq(s.turn_order[0].initials, "EL")
|
||||||
|
assert_true(s.turn_order[0].is_active, "first combatant is the active turn")
|
||||||
|
assert_true(s.turn_order[3].is_downed, "last combatant is downed")
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_consumables():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.consumables.size(), 8)
|
||||||
|
assert_eq(s.consumables[0]["label"], "Salve")
|
||||||
|
assert_eq(s.consumables[0]["hotkey"], 1)
|
||||||
|
assert_eq(s.consumables[6]["label"], "", "slot 7 is empty")
|
||||||
|
|
||||||
|
|
||||||
|
func test_toggle_dock_flips():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_true(s.dock_open, "seed opens with the dock out")
|
||||||
|
assert_false(s.toggle_dock())
|
||||||
|
assert_false(s.dock_open)
|
||||||
|
assert_true(s.toggle_dock())
|
||||||
|
assert_true(s.dock_open)
|
||||||
1
client/tests/unit/test_shell_state.gd.uid
Normal file
1
client/tests/unit/test_shell_state.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://wkbpiulrv8rx
|
||||||
32
client/tests/unit/test_system_dock.gd
Normal file
32
client/tests/unit/test_system_dock.gd
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/SystemDock.tscn"
|
||||||
|
|
||||||
|
|
||||||
|
func _dock() -> SystemDock:
|
||||||
|
var d = load(SCENE).instantiate()
|
||||||
|
add_child_autofree(d) # _ready() builds the buttons
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
func test_builds_all_six_screen_buttons():
|
||||||
|
var d := _dock()
|
||||||
|
assert_eq(d._screen_buttons.size(), 6)
|
||||||
|
assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity")
|
||||||
|
assert_true(d._screen_buttons.has(&"party"))
|
||||||
|
|
||||||
|
|
||||||
|
func test_pressing_a_button_emits_its_id():
|
||||||
|
var d := _dock()
|
||||||
|
watch_signals(d)
|
||||||
|
d._screen_buttons[&"inventory"].emit_signal("pressed")
|
||||||
|
assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_handle_toggles_state():
|
||||||
|
var d := _dock()
|
||||||
|
var state := ShellState.seed()
|
||||||
|
d.setup(state)
|
||||||
|
assert_true(state.dock_open)
|
||||||
|
d._on_handle_pressed()
|
||||||
|
assert_false(state.dock_open, "handle flips the shared state")
|
||||||
1
client/tests/unit/test_system_dock.gd.uid
Normal file
1
client/tests/unit/test_system_dock.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dllvm2kmia00h
|
||||||
86
docs/adr/0001-editor-first-ui-scenes.md
Normal file
86
docs/adr/0001-editor-first-ui-scenes.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# 0001 — UI screens are editor-authored scenes, not code-built
|
||||||
|
|
||||||
|
- **Status:** accepted
|
||||||
|
- **Date:** 2026-07-11 (M3-b, Main Window shell)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The M3-b Main Window shell was first built by constructing every Control node in
|
||||||
|
`_ready()` (a pattern borrowed from `theme_showcase.gd`, where it was fine because
|
||||||
|
the showcase is a throwaway proof). For a real screen this was wrong:
|
||||||
|
|
||||||
|
- **It does not preview in the Godot editor.** Opening the `.tscn` showed a bare
|
||||||
|
root Control — every widget only existed at runtime — so the human could not see
|
||||||
|
or arrange the layout in the editor. Tuning meant editing pixel offsets in code,
|
||||||
|
running, eyeballing, repeating.
|
||||||
|
- It buried layout, styling, and content in GDScript, where it is verbose and
|
||||||
|
hard to reason about, and it drifted from charter §16's own words: *"each screen
|
||||||
|
is recreated as a Godot 4.7 Control-node scene."*
|
||||||
|
|
||||||
|
The human asked to be able to arrange the scene in the editor "as much as
|
||||||
|
possible," with code only for genuine on-load work.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**UI screens (and their sub-scenes) are authored editor-first as `.tscn` node
|
||||||
|
trees.** The split:
|
||||||
|
|
||||||
|
- **In the `.tscn` (editor):** the whole node tree — containers, panels, labels,
|
||||||
|
buttons, art-slot placeholders — with the shared `Theme` assigned on the scene
|
||||||
|
root and every `theme_type_variation` set on its node. Authored placeholder
|
||||||
|
text/values so the screen previews with representative content. Sub-screens are
|
||||||
|
**instanced** (`ExtResource` PackedScene), not rebuilt.
|
||||||
|
- **In the script (on-load only):** `@onready` references to named nodes; wiring
|
||||||
|
(signals, the DM loop, injected services); animations (e.g. the dock slide);
|
||||||
|
and **binding state into the authored nodes** (setting label text/colours from
|
||||||
|
the model). Never construct the tree in `_ready()`.
|
||||||
|
- **Data-driven repeats** (turn-order tokens, consumable slots, later inventory
|
||||||
|
grids) are a **fixed set of authored nodes** bound from state at runtime —
|
||||||
|
spare slots hide. This keeps them previewable; combat/inventory become the
|
||||||
|
writers later. (Author enough slots for the common case; if a screen ever needs
|
||||||
|
a genuinely unbounded list, revisit with a code-instanced container then.)
|
||||||
|
|
||||||
|
Supporting conventions established alongside this decision:
|
||||||
|
|
||||||
|
- **Scene root sizing.** Give the `.tscn` root a fixed size (e.g. 1920×1080) via
|
||||||
|
offsets so it previews, but **no full-rect anchors on the root** — a root
|
||||||
|
Control run via F6 is not sized by full-rect anchors and collapses to (0,0).
|
||||||
|
The script keeps `size = get_viewport_rect().size` on `_ready` +
|
||||||
|
`size_changed` (the M3-a lesson).
|
||||||
|
- **Button styling uses Button-base theme variations.** Never apply a
|
||||||
|
PanelContainer-base variation (`DarkPanel`, `ParchmentCard`) to a `Button` — a
|
||||||
|
Button looks up `normal/hover/pressed`, which a `panel`-only variation lacks, so
|
||||||
|
it renders unskinned. M3-b added Button-base `DockButton` and `ParchmentButton`
|
||||||
|
to the theme for exactly this.
|
||||||
|
- **RichTextLabel prose reads its style from the theme.** `RichTextLabel` does
|
||||||
|
**not** inherit `default_font`/`default_color`, so without a theme entry its
|
||||||
|
text renders near-white (unreadable on parchment). The theme now sets a base
|
||||||
|
`RichTextLabel` style (dark `INK_BODY` serif + italic face). All colours still
|
||||||
|
come from `Palette` via the theme builder — no hex in scripts or `.tscn`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Easier:**
|
||||||
|
|
||||||
|
- The human arranges any screen directly in the Godot editor; layout persists in
|
||||||
|
the `.tscn`. Fine-tuning is drag-and-drop, not code-run-eyeball.
|
||||||
|
- Screens preview with themed, representative content on open.
|
||||||
|
- Scripts shrink to logic; less code, easier to review, focused files.
|
||||||
|
- The look stays one system (theme assigned per scene root).
|
||||||
|
|
||||||
|
**Caveats / harder:**
|
||||||
|
|
||||||
|
- **Shader surfaces render only at runtime.** `DarkBay`/`VignetteOverlay` set
|
||||||
|
their `ShaderMaterial` in `_ready()` (M3-a accepted deviation), so their
|
||||||
|
texture appears only on an F6 run — the editor shows layout but a plain panel
|
||||||
|
behind it. Layout still previews fine.
|
||||||
|
- Tests reference nodes by their authored `@onready` paths; renaming/moving a
|
||||||
|
node in the editor means updating the path (and any test that reads it).
|
||||||
|
- The visual gate stays a **human F6 eyeball** — headless tests prove structure
|
||||||
|
(nodes mount, sizes are non-zero, bindings run) but not rendering.
|
||||||
|
|
||||||
|
## Reference implementation
|
||||||
|
|
||||||
|
M3-b shell scenes: `client/scenes/shell/{MainWindowShell,SystemDock,NarrationBook}.tscn`
|
||||||
|
with their `client/scripts/ui/shell/*.gd` scripts. Practical how-to lives in
|
||||||
|
[`client/docs/README.md`](../../client/docs/README.md) → "UI scene conventions."
|
||||||
@@ -4,7 +4,7 @@ One file per decision: `NNNN-short-title.md`. Record the context, the decision,
|
|||||||
|
|
||||||
## Index
|
## Index
|
||||||
|
|
||||||
- _none yet_
|
- [0001 — UI screens are editor-authored scenes, not code-built](0001-editor-first-ui-scenes.md) (accepted)
|
||||||
|
|
||||||
<!-- Template:
|
<!-- Template:
|
||||||
## NNNN — Title
|
## NNNN — Title
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ M0–M2 built the **engine** (the contract, the client, the server loop, bounded
|
|||||||
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each `.dc.html`'s `Component` class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
|
Each screen is recreated as a Godot 4.7 Control-node scene against the mockups (§16); each `.dc.html`'s `Component` class is the behavior spec. Sequenced toward a playable vertical slice (shell → creation → combat → dialogue) first, then breadth.
|
||||||
|
|
||||||
### M3 — Visual foundation & shell ◀ *here*
|
### M3 — Visual foundation & shell ◀ *here*
|
||||||
- ○ **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
|
- ✅ **Shared `Theme`** — palette tokens, the three font families, and reusable styleboxes (parchment card, dark panel, primary CTA, tab, item tile, tag/chip) from the mock README as a Godot `Theme` resource. Every screen pulls from it; build it first so the look is one system. Merged to `dev`. *§2: n/a (presentation) · depends on nothing · goal: one consistent visual identity.*
|
||||||
- ○ **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
|
- ✅ **Main Window shell (2a)** — the exploration HUD: isometric world-view slot + the permanent DM "narration book" (wired to the proven `/dm/narrate` loop + considering-state), the turn-order rail, minimap slot, the slide-in system dock (Inventory/Character/Quest/Map/Party/Spellbook toggles), and the bottom command bar (HP/MP/gold, consumables, End Turn). The frame everything else lives in. **Established the editor-first UI-scene convention** (ADR [0001](adr/0001-editor-first-ui-scenes.md); how-to in `client/docs/README.md`) and added Button-base `DockButton`/`ParchmentButton` + a base `RichTextLabel` prose style to the theme. *§2: state (client owns the HUD, consumes DM text) · depends on the DM loop (M2) · goal: the game's main screen, on screen.*
|
||||||
- ○ **Title screen** — new game / continue / load / settings; the entry point. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
- ○ **Title screen** — new game / continue / load / settings; the entry point. Author it editor-first (ADR 0001). *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||||
|
|
||||||
### M4 — Character creation
|
### M4 — Character creation
|
||||||
- ○ **Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
- ○ **Character creation UI** — the mock's screen over our proven model: race + calling cards, six stats with **LCK hidden entirely** (§7), 3d6 floors, the **+3 additive-only spend-up** pool (§8, matches the `character-creation-stats` model), live origin blurb + nameplate. **Reconcile class names** here (§8 vs the mock's callings). Feeds new-game canon-log construction (already built). *§2: state · depends on the creation model (M0) + Theme · goal: build a character.*
|
||||||
|
|||||||
888
docs/superpowers/plans/2026-07-11-main-window-shell.md
Normal file
888
docs/superpowers/plans/2026-07-11-main-window-shell.md
Normal file
@@ -0,0 +1,888 @@
|
|||||||
|
# Main Window shell (2a) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the exploration HUD — a two-panel "tabletop split" frame with an isometric world-view slot, a permanent DM narration book wired to the proven `/dm/narrate` loop (prose-only + refire), a slide-in system dock, and a command bar — over a thin, tested `ShellState`.
|
||||||
|
|
||||||
|
**Architecture:** House pattern — pure, tested cores behind thin scene shims (like `DmService`/`ConsideringPhrases`). `ShellState` is a tested value model; `SystemDock`/`NarrationBook`/`MainWindowShell` are Control scenes whose logic seams (signal emission, prose consume, dep injection) are unit-tested and whose layout is eyeball-gated by an F6 run. The narration book is the one live seam; everything else is faithful placeholder.
|
||||||
|
|
||||||
|
**Tech Stack:** Godot 4.7, GDScript, GUT (headless). Reuses the M3-a Theme (`game_theme.tres`, `ThemeKeys`, `Palette`, surface scenes) and the M2 DM loop (`DmService`, `HttpTransport`, `FallbackLibrary`, `ConsideringIndicator`, `CanonLog`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Godot 4.7 / GDScript.** No C#. Match surrounding style.
|
||||||
|
- **`Palette` is the only home for hex colours.** No colour literal may live in any new file — read `Palette.*`.
|
||||||
|
- **Theme variations via `ThemeKeys`** — set `theme_type_variation` to a `ThemeKeys` constant, never a raw string.
|
||||||
|
- **`§2`: code owns state, AI owns text.** `ShellState` and HUD numbers are state (client-owned); narration prose is text (consumed from `/dm/narrate`, never a source of truth — only harvested `[FACT]` tags become state).
|
||||||
|
- **Degrade, never error (§13).** Any non-200 → `DmService` already returns the authored fallback. Add no new failure surface.
|
||||||
|
- **Never surface LCK / any Luck number (§7).** Not in the HUD, not in the seed log's rendered fields.
|
||||||
|
- **New `class_name` scripts need an import pass before GUT resolves them.** `./run_tests.sh` runs `godot --headless --import` first, so always test through it.
|
||||||
|
- **Canvas is 1920×1080.** World panel 1180 wide, narration book 740 wide.
|
||||||
|
- **Root-Control viewport-fit gotcha:** a root `Control` run via F6 is not sized by full-rect anchors — it collapses to `(0,0)`. `MainWindowShell` must set `size = get_viewport_rect().size` in `_ready()` and reconnect on `size_changed`. Its `.tscn` root must NOT set `anchors_preset`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: `ShellState` + `TurnEntry` (the HUD state model)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/scripts/ui/shell/turn_entry.gd`
|
||||||
|
- Create: `client/scripts/ui/shell/shell_state.gd`
|
||||||
|
- Test: `client/tests/unit/test_shell_state.gd`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing.
|
||||||
|
- Produces:
|
||||||
|
- `class_name TurnEntry extends RefCounted` — `_init(initials: String, initiative: int, side: StringName, is_active := false, is_downed := false)`; fields `initials: String`, `initiative: int`, `side: StringName` (`&"you"`/`&"ally"`/`&"enemy"`), `is_active: bool`, `is_downed: bool`.
|
||||||
|
- `class_name ShellState extends RefCounted` — fields `vitals: Dictionary` (keys `hp`,`hp_max`,`mp`,`mp_max`,`gold`), `turn_order: Array[TurnEntry]`, `consumables: Array` (8 dicts `{hotkey:int, label:String}`; `label==""` is an empty slot), `round_label: String`, `location_label: String`, `dock_open: bool`. Methods `func toggle_dock() -> bool` (flips `dock_open`, returns new value); `static func seed() -> ShellState` (the mock-2a values).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `client/tests/unit/test_shell_state.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func test_turn_entry_holds_fields():
|
||||||
|
var e := TurnEntry.new("DW", 12, &"ally")
|
||||||
|
assert_eq(e.initials, "DW")
|
||||||
|
assert_eq(e.initiative, 12)
|
||||||
|
assert_eq(e.side, &"ally")
|
||||||
|
assert_false(e.is_active)
|
||||||
|
assert_false(e.is_downed)
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_vitals():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.vitals["hp"], 42)
|
||||||
|
assert_eq(s.vitals["hp_max"], 60)
|
||||||
|
assert_eq(s.vitals["mp"], 18)
|
||||||
|
assert_eq(s.vitals["mp_max"], 30)
|
||||||
|
assert_eq(s.vitals["gold"], 1240)
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_turn_order():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.turn_order.size(), 4)
|
||||||
|
assert_eq(s.turn_order[0].initials, "EL")
|
||||||
|
assert_true(s.turn_order[0].is_active, "first combatant is the active turn")
|
||||||
|
assert_true(s.turn_order[3].is_downed, "last combatant is downed")
|
||||||
|
|
||||||
|
|
||||||
|
func test_seed_consumables():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_eq(s.consumables.size(), 8)
|
||||||
|
assert_eq(s.consumables[0]["label"], "Salve")
|
||||||
|
assert_eq(s.consumables[0]["hotkey"], 1)
|
||||||
|
assert_eq(s.consumables[6]["label"], "", "slot 7 is empty")
|
||||||
|
|
||||||
|
|
||||||
|
func test_toggle_dock_flips():
|
||||||
|
var s := ShellState.seed()
|
||||||
|
assert_true(s.dock_open, "seed opens with the dock out")
|
||||||
|
assert_false(s.toggle_dock())
|
||||||
|
assert_false(s.dock_open)
|
||||||
|
assert_true(s.toggle_dock())
|
||||||
|
assert_true(s.dock_open)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd`
|
||||||
|
Expected: FAIL — `TurnEntry`/`ShellState` are unknown identifiers (parse/compile error reported by GUT).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `client/scripts/ui/shell/turn_entry.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name TurnEntry
|
||||||
|
extends RefCounted
|
||||||
|
## One combatant in the turn-order rail (§2: state). Seed/placeholder now; the
|
||||||
|
## combat turn manager (M5) becomes its writer. `side` colours the rail:
|
||||||
|
## &"you" gold, &"ally" teal-muted, &"enemy" blood.
|
||||||
|
|
||||||
|
var initials: String
|
||||||
|
var initiative: int
|
||||||
|
var side: StringName
|
||||||
|
var is_active: bool
|
||||||
|
var is_downed: bool
|
||||||
|
|
||||||
|
|
||||||
|
func _init(p_initials: String, p_initiative: int, p_side: StringName,
|
||||||
|
p_is_active := false, p_is_downed := false) -> void:
|
||||||
|
initials = p_initials
|
||||||
|
initiative = p_initiative
|
||||||
|
side = p_side
|
||||||
|
is_active = p_is_active
|
||||||
|
is_downed = p_is_downed
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `client/scripts/ui/shell/shell_state.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name ShellState
|
||||||
|
extends RefCounted
|
||||||
|
## The HUD state the Main Window shell owns (§2: state). Seed values mirror mock
|
||||||
|
## 2a; later systems (combat vitals, inventory consumables) become the writers.
|
||||||
|
## No Luck/LCK value lives here — it is never surfaced (§7).
|
||||||
|
|
||||||
|
var vitals: Dictionary = {"hp": 0, "hp_max": 0, "mp": 0, "mp_max": 0, "gold": 0}
|
||||||
|
var turn_order: Array[TurnEntry] = []
|
||||||
|
var consumables: Array = []
|
||||||
|
var round_label: String = ""
|
||||||
|
var location_label: String = ""
|
||||||
|
var dock_open: bool = true
|
||||||
|
|
||||||
|
|
||||||
|
func toggle_dock() -> bool:
|
||||||
|
dock_open = not dock_open
|
||||||
|
return dock_open
|
||||||
|
|
||||||
|
|
||||||
|
static func seed() -> ShellState:
|
||||||
|
var s := ShellState.new()
|
||||||
|
s.vitals = {"hp": 42, "hp_max": 60, "mp": 18, "mp_max": 30, "gold": 1240}
|
||||||
|
s.turn_order = [
|
||||||
|
TurnEntry.new("EL", 17, &"you", true, false),
|
||||||
|
TurnEntry.new("DW", 12, &"ally"),
|
||||||
|
TurnEntry.new("HU", 9, &"ally"),
|
||||||
|
TurnEntry.new("GK", 0, &"enemy", false, true),
|
||||||
|
]
|
||||||
|
var labels := ["Salve", "Draught", "Bomb", "Antidote", "Ration", "Torch", "", ""]
|
||||||
|
s.consumables = []
|
||||||
|
for i in range(labels.size()):
|
||||||
|
s.consumables.append({"hotkey": i + 1, "label": labels[i]})
|
||||||
|
s.round_label = "ROUND 4"
|
||||||
|
s.location_label = "THE LOWER WARD"
|
||||||
|
s.dock_open = true
|
||||||
|
return s
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_shell_state.gd`
|
||||||
|
Expected: PASS — 5 passing tests, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/scripts/ui/shell/turn_entry.gd client/scripts/ui/shell/shell_state.gd client/tests/unit/test_shell_state.gd client/scripts/ui/shell/*.uid client/tests/unit/test_shell_state.gd.uid
|
||||||
|
git commit -m "feat(shell): ShellState + TurnEntry HUD state model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `SystemDock` (slide-in dock, inert routing seam)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/scripts/ui/shell/system_dock.gd`
|
||||||
|
- Create: `client/scenes/shell/SystemDock.tscn`
|
||||||
|
- Test: `client/tests/unit/test_system_dock.gd`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ShellState` (Task 1) — reads/flips `dock_open`.
|
||||||
|
- Produces:
|
||||||
|
- `class_name SystemDock extends Control` — scene `res://scenes/shell/SystemDock.tscn`.
|
||||||
|
- `signal screen_requested(id: StringName)` — emitted when a dock button is pressed (inert this milestone; the routing seam).
|
||||||
|
- `const SCREENS := [[&"inventory","Inventory","I"], [&"character","Character","C"], [&"quest_log","Quest Log","J"], [&"spellbook","Spellbook","K"], [&"world_map","World Map","M"], [&"party","Party","P"]]`
|
||||||
|
- `var _screen_buttons: Dictionary` — `id: StringName -> Button`, populated in `_ready()`.
|
||||||
|
- `func setup(state: ShellState) -> void` — binds state, applies initial open.
|
||||||
|
- `func _on_handle_pressed() -> void` — flips `state.dock_open`, animates.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `client/tests/unit/test_system_dock.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/SystemDock.tscn"
|
||||||
|
|
||||||
|
|
||||||
|
func _dock() -> SystemDock:
|
||||||
|
var d = load(SCENE).instantiate()
|
||||||
|
add_child_autofree(d) # _ready() builds the buttons
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
func test_builds_all_six_screen_buttons():
|
||||||
|
var d := _dock()
|
||||||
|
assert_eq(d._screen_buttons.size(), 6)
|
||||||
|
assert_true(d._screen_buttons.has(&"spellbook"), "spellbook kept for mock fidelity")
|
||||||
|
assert_true(d._screen_buttons.has(&"party"))
|
||||||
|
|
||||||
|
|
||||||
|
func test_pressing_a_button_emits_its_id():
|
||||||
|
var d := _dock()
|
||||||
|
watch_signals(d)
|
||||||
|
d._screen_buttons[&"inventory"].emit_signal("pressed")
|
||||||
|
assert_signal_emitted_with_parameters(d, "screen_requested", [&"inventory"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_handle_toggles_state():
|
||||||
|
var d := _dock()
|
||||||
|
var state := ShellState.seed()
|
||||||
|
d.setup(state)
|
||||||
|
assert_true(state.dock_open)
|
||||||
|
d._on_handle_pressed()
|
||||||
|
assert_false(state.dock_open, "handle flips the shared state")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd`
|
||||||
|
Expected: FAIL — cannot load `SystemDock.tscn` / `SystemDock` unknown.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `client/scripts/ui/shell/system_dock.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name SystemDock
|
||||||
|
extends Control
|
||||||
|
## The slide-in system dock (mock 2a): a gold handle toggles a column of screen
|
||||||
|
## buttons in/out. §2: state (owns the open toggle via ShellState). The buttons
|
||||||
|
## are the routing seam — they emit screen_requested; the screens they open land
|
||||||
|
## in later milestones, so the signal is currently a no-op for the shell.
|
||||||
|
|
||||||
|
signal screen_requested(id: StringName)
|
||||||
|
|
||||||
|
const SCREENS := [
|
||||||
|
[&"inventory", "Inventory", "I"],
|
||||||
|
[&"character", "Character", "C"],
|
||||||
|
[&"quest_log", "Quest Log", "J"],
|
||||||
|
[&"spellbook", "Spellbook", "K"],
|
||||||
|
[&"world_map", "World Map", "M"],
|
||||||
|
[&"party", "Party", "P"],
|
||||||
|
]
|
||||||
|
const SLIDE_SECONDS := 0.32
|
||||||
|
const HIDDEN_SHIFT := 230.0
|
||||||
|
|
||||||
|
var _screen_buttons: Dictionary = {}
|
||||||
|
var _state: ShellState
|
||||||
|
var _menu: VBoxContainer
|
||||||
|
var _handle: Button
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_menu = VBoxContainer.new()
|
||||||
|
_menu.add_theme_constant_override("separation", 10)
|
||||||
|
add_child(_menu)
|
||||||
|
for entry in SCREENS:
|
||||||
|
var id: StringName = entry[0]
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = "%s %s" % [entry[2], entry[1]]
|
||||||
|
button.theme_type_variation = ThemeKeys.DARK_PANEL
|
||||||
|
button.pressed.connect(func(): screen_requested.emit(id))
|
||||||
|
_menu.add_child(button)
|
||||||
|
_screen_buttons[id] = button
|
||||||
|
|
||||||
|
_handle = Button.new()
|
||||||
|
_handle.text = "›"
|
||||||
|
_handle.theme_type_variation = ThemeKeys.TAB_ACTIVE
|
||||||
|
_handle.pressed.connect(_on_handle_pressed)
|
||||||
|
add_child(_handle)
|
||||||
|
|
||||||
|
|
||||||
|
func setup(state: ShellState) -> void:
|
||||||
|
_state = state
|
||||||
|
_apply_open(_state.dock_open)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_handle_pressed() -> void:
|
||||||
|
if _state == null:
|
||||||
|
return
|
||||||
|
_apply_open(_state.toggle_dock())
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_open(open: bool) -> void:
|
||||||
|
_handle.text = "›" if open else "‹"
|
||||||
|
var target := Vector2.ZERO if open else Vector2(HIDDEN_SHIFT, 0.0)
|
||||||
|
var alpha := 1.0 if open else 0.0
|
||||||
|
if not is_inside_tree():
|
||||||
|
_menu.position = target
|
||||||
|
_menu.modulate.a = alpha
|
||||||
|
return
|
||||||
|
var tween := create_tween().set_parallel(true)
|
||||||
|
tween.tween_property(_menu, "position", target, SLIDE_SECONDS)
|
||||||
|
tween.tween_property(_menu, "modulate:a", alpha, SLIDE_SECONDS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `client/scenes/shell/SystemDock.tscn`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/system_dock.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="SystemDock" type="Control"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_system_dock.gd`
|
||||||
|
Expected: PASS — 3 passing tests, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/scripts/ui/shell/system_dock.gd client/scenes/shell/SystemDock.tscn client/tests/unit/test_system_dock.gd client/scripts/ui/shell/system_dock.gd.uid client/scenes/shell/SystemDock.tscn.uid client/tests/unit/test_system_dock.gd.uid
|
||||||
|
git commit -m "feat(shell): SystemDock slide-in with inert screen-routing seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `NarrationBook` (the live DM panel + refire)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/scripts/ui/shell/narration_book.gd`
|
||||||
|
- Create: `client/scenes/shell/NarrationBook.tscn`
|
||||||
|
- Test: `client/tests/unit/test_narration_book.gd`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `DmService` (`narrate(canon_log) -> NarrateResult`), `CanonLog`, `ShellState`, `ConsideringIndicator`, `NarrateResult` (`display_text`, `facts`, `degraded`).
|
||||||
|
- Produces:
|
||||||
|
- `class_name NarrationBook extends Control` — scene `res://scenes/shell/NarrationBook.tscn`.
|
||||||
|
- `func setup(service: DmService, log: CanonLog, state: ShellState) -> void` — injects deps, fills the header.
|
||||||
|
- `func narrate() -> void` — **async**; runs the refire flow (considering-state → await → prose → fact-harvest).
|
||||||
|
- `func show_prose(result: NarrateResult) -> void` — sets the prose text, records `last_degraded`.
|
||||||
|
- `var last_degraded: bool` — set by `show_prose` (test seam + degraded UI).
|
||||||
|
- `var _prose: RichTextLabel` — the narration surface.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `client/tests/unit/test_narration_book.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/NarrationBook.tscn"
|
||||||
|
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||||
|
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _book_with(response) -> NarrationBook:
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(response)
|
||||||
|
var service := DmService.new(transport, FallbackLibrary.new())
|
||||||
|
var book = load(SCENE).instantiate()
|
||||||
|
add_child_autofree(book) # _ready() builds nodes + the considering indicator
|
||||||
|
book.setup(service, CanonLog.new(), ShellState.seed())
|
||||||
|
return book
|
||||||
|
|
||||||
|
|
||||||
|
func test_narrate_shows_prose_and_harvests_facts():
|
||||||
|
var book := _book_with(DmResponse.ok(200,
|
||||||
|
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
|
||||||
|
var log := CanonLog.new()
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(DmResponse.ok(200,
|
||||||
|
{"prose": "The rain runs black into the gutter. [FACT: the eastern bridge is out]"}))
|
||||||
|
book.setup(DmService.new(transport, FallbackLibrary.new()), log, ShellState.seed())
|
||||||
|
await book.narrate()
|
||||||
|
assert_string_contains(book._prose.text, "The rain runs black into the gutter.")
|
||||||
|
assert_false(book._prose.text.contains("[FACT"), "tags are stripped from displayed prose")
|
||||||
|
assert_true("the eastern bridge is out" in log.established_facts, "fact harvested to the log")
|
||||||
|
assert_false(book.last_degraded)
|
||||||
|
|
||||||
|
|
||||||
|
func test_degraded_response_shows_fallback_and_sets_flag():
|
||||||
|
var book := _book_with(DmResponse.failed("proxy down"))
|
||||||
|
await book.narrate()
|
||||||
|
assert_true(book.last_degraded, "a failed call degrades")
|
||||||
|
assert_ne(book._prose.text, "", "the fallback line is still shown (§13)")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd`
|
||||||
|
Expected: FAIL — cannot load `NarrationBook.tscn` / `NarrationBook` unknown.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `client/scripts/ui/shell/narration_book.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name NarrationBook
|
||||||
|
extends Control
|
||||||
|
## The permanent DM narration book (mock 2a, right panel). §2: text — it consumes
|
||||||
|
## /dm/narrate prose and never treats it as truth; only harvested [FACT] tags are
|
||||||
|
## written back to the canon log (§11). Prose-only this milestone: the numbered
|
||||||
|
## response choices and the "describe your own action" line are faithful but inert
|
||||||
|
## (no action system yet); one in-world refire affordance re-runs narration.
|
||||||
|
|
||||||
|
var last_degraded: bool = false
|
||||||
|
|
||||||
|
var _service: DmService
|
||||||
|
var _log: CanonLog
|
||||||
|
var _state: ShellState
|
||||||
|
var _header: Label
|
||||||
|
var _prose: RichTextLabel
|
||||||
|
var _status: Label
|
||||||
|
var _refire: Button
|
||||||
|
var _degraded_tag: Label
|
||||||
|
var _indicator: ConsideringIndicator
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
var col := VBoxContainer.new()
|
||||||
|
col.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
col.add_theme_constant_override("separation", 18)
|
||||||
|
add_child(col)
|
||||||
|
|
||||||
|
_header = Label.new()
|
||||||
|
_header.theme_type_variation = ThemeKeys.ACCENT
|
||||||
|
col.add_child(_header)
|
||||||
|
|
||||||
|
_prose = RichTextLabel.new()
|
||||||
|
_prose.bbcode_enabled = true
|
||||||
|
_prose.fit_content = true
|
||||||
|
_prose.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
|
_prose.custom_minimum_size = Vector2(660, 320)
|
||||||
|
col.add_child(_prose)
|
||||||
|
|
||||||
|
_degraded_tag = Label.new()
|
||||||
|
_degraded_tag.theme_type_variation = ThemeKeys.MONO
|
||||||
|
_degraded_tag.text = "THE MASTER'S VOICE COMES THIN" # shown only when degraded
|
||||||
|
_degraded_tag.visible = false
|
||||||
|
col.add_child(_degraded_tag)
|
||||||
|
|
||||||
|
# Faithful-but-inert placeholders (charter: prose-only this milestone).
|
||||||
|
var prompt := Label.new()
|
||||||
|
prompt.theme_type_variation = ThemeKeys.ACCENT
|
||||||
|
prompt.text = "▸ Your turn. What do you do?"
|
||||||
|
col.add_child(prompt)
|
||||||
|
for line in [
|
||||||
|
"1 · Pay the fence double and keep your hand where he can see it",
|
||||||
|
"2 · Draw your blade — he's closer than his guards",
|
||||||
|
"3 · Lie — tell him the rest is coming with your dwarf"]:
|
||||||
|
var choice := Button.new()
|
||||||
|
choice.theme_type_variation = ThemeKeys.PARCHMENT_CARD
|
||||||
|
choice.text = line
|
||||||
|
choice.disabled = true # inert until combat/dialogue give choices meaning
|
||||||
|
col.add_child(choice)
|
||||||
|
var free_text := Label.new()
|
||||||
|
free_text.theme_type_variation = ThemeKeys.MONO
|
||||||
|
free_text.text = "⌨ …or describe your own action"
|
||||||
|
col.add_child(free_text)
|
||||||
|
|
||||||
|
# The one live control: refire the narration.
|
||||||
|
_refire = Button.new()
|
||||||
|
_refire.theme_type_variation = ThemeKeys.PARCHMENT_CARD
|
||||||
|
_refire.text = "▸ The Master continues…"
|
||||||
|
_refire.pressed.connect(_on_refire_pressed)
|
||||||
|
col.add_child(_refire)
|
||||||
|
|
||||||
|
_status = Label.new()
|
||||||
|
_status.theme_type_variation = ThemeKeys.MONO
|
||||||
|
col.add_child(_status)
|
||||||
|
|
||||||
|
_indicator = ConsideringIndicator.new()
|
||||||
|
add_child(_indicator)
|
||||||
|
|
||||||
|
|
||||||
|
func setup(service: DmService, log: CanonLog, state: ShellState) -> void:
|
||||||
|
_service = service
|
||||||
|
_log = log
|
||||||
|
_state = state
|
||||||
|
_header.text = "%s · %s" % [state.round_label, state.location_label]
|
||||||
|
|
||||||
|
|
||||||
|
func narrate() -> void:
|
||||||
|
if _service == null or _log == null:
|
||||||
|
return
|
||||||
|
_refire.disabled = true
|
||||||
|
_indicator.start(_status)
|
||||||
|
var result: NarrateResult = await _service.narrate(_log)
|
||||||
|
_indicator.stop()
|
||||||
|
show_prose(result)
|
||||||
|
for f in result.facts:
|
||||||
|
_log.add_fact(f) # §11: code applies the state write, explicitly
|
||||||
|
_refire.disabled = false
|
||||||
|
|
||||||
|
|
||||||
|
func show_prose(result: NarrateResult) -> void:
|
||||||
|
_prose.text = result.display_text
|
||||||
|
last_degraded = result.degraded
|
||||||
|
_degraded_tag.visible = result.degraded
|
||||||
|
|
||||||
|
|
||||||
|
func _on_refire_pressed() -> void:
|
||||||
|
narrate()
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `client/scenes/shell/NarrationBook.tscn`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/narration_book.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="NarrationBook" type="Control"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_narration_book.gd`
|
||||||
|
Expected: PASS — 2 passing tests, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/scripts/ui/shell/narration_book.gd client/scenes/shell/NarrationBook.tscn client/tests/unit/test_narration_book.gd client/scripts/ui/shell/narration_book.gd.uid client/scenes/shell/NarrationBook.tscn.uid client/tests/unit/test_narration_book.gd.uid
|
||||||
|
git commit -m "feat(shell): NarrationBook wired to /dm/narrate with refire + fact-harvest"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `MainWindowShell` (assemble the frame + wire the DM loop)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/scripts/ui/shell/main_window_shell.gd`
|
||||||
|
- Create: `client/scenes/shell/MainWindowShell.tscn`
|
||||||
|
- Test: `client/tests/unit/test_main_window_shell.gd`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `ShellState` (Task 1), `SystemDock` (Task 2), `NarrationBook` (Task 3), `DmService`/`HttpTransport`/`FallbackLibrary`, `CanonLog`/`LogPlayer`/`PartyMember`/`Quest`, the `DarkBay` surface scene, `Palette`, `ThemeKeys`.
|
||||||
|
- Produces:
|
||||||
|
- `class_name MainWindowShell extends Control` — scene `res://scenes/shell/MainWindowShell.tscn`; the F6 target.
|
||||||
|
- `var service: DmService = null` — **injection seam**: settable between `instantiate()` and `add_child()`; when null, `_ready()` builds the real `HttpTransport`-backed service. Tests inject a `FakeTransport`-backed service so the initial narrate is hermetic.
|
||||||
|
- `var book: NarrationBook`, `var dock: SystemDock`, `var _state: ShellState`, `var _log: CanonLog`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `client/tests/unit/test_main_window_shell.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
extends "res://addons/gut/test.gd"
|
||||||
|
|
||||||
|
const SCENE := "res://scenes/shell/MainWindowShell.tscn"
|
||||||
|
const FakeTransport = preload("res://tests/doubles/fake_transport.gd")
|
||||||
|
const DmResponse = preload("res://scripts/net/dm_response.gd")
|
||||||
|
|
||||||
|
|
||||||
|
func _shell() -> MainWindowShell:
|
||||||
|
# Inject a fake service BEFORE _ready (add_child) so the initial narrate does
|
||||||
|
# not touch the network — instantiate() does not run _ready(); add_child does.
|
||||||
|
var node = load(SCENE).instantiate()
|
||||||
|
var transport = FakeTransport.new()
|
||||||
|
transport.set_response(DmResponse.ok(200, {"prose": "Rain on the cobbles."}))
|
||||||
|
node.service = DmService.new(transport, FallbackLibrary.new())
|
||||||
|
add_child_autofree(node)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_applies_theme_and_fits_viewport():
|
||||||
|
var packed = load(SCENE)
|
||||||
|
assert_true(packed is PackedScene)
|
||||||
|
var node := _shell()
|
||||||
|
assert_true(node is Control)
|
||||||
|
assert_true(node.theme is Theme, "shell applies game_theme")
|
||||||
|
assert_gt(node.size.x, 0.0, "viewport-fit sized the root (M3-a collapse guard)")
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_mounts_both_panels():
|
||||||
|
var node := _shell()
|
||||||
|
assert_not_null(node.book, "narration book mounted")
|
||||||
|
assert_not_null(node.dock, "system dock mounted")
|
||||||
|
|
||||||
|
|
||||||
|
func test_shell_builds_a_code_owned_seed_log():
|
||||||
|
var node := _shell()
|
||||||
|
assert_not_null(node._log, "a canon log exists to post (§11)")
|
||||||
|
assert_gt(node._log.established_facts.size(), 0, "seed scene facts are code-owned")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd`
|
||||||
|
Expected: FAIL — cannot load `MainWindowShell.tscn` / `MainWindowShell` unknown.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
Create `client/scripts/ui/shell/main_window_shell.gd`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
class_name MainWindowShell
|
||||||
|
extends Control
|
||||||
|
## The exploration HUD — the frame every screen lives in (mock 2a). Two-panel
|
||||||
|
## tabletop split: a dark isometric world slot (turn rail, minimap, system dock,
|
||||||
|
## command bar) + the permanent DM narration book. §2: the shell owns HUD state
|
||||||
|
## (ShellState) and consumes DM text (never owns narrative truth). It reuses the
|
||||||
|
## proven Theme (M3-a) and DM loop (M2) — nothing here is reinvented.
|
||||||
|
|
||||||
|
const GAME_THEME := "res://assets/theme/game_theme.tres"
|
||||||
|
const DARK_BAY := "res://scenes/theme/surfaces/DarkBay.tscn"
|
||||||
|
const SYSTEM_DOCK := "res://scenes/shell/SystemDock.tscn"
|
||||||
|
const NARRATION_BOOK := "res://scenes/shell/NarrationBook.tscn"
|
||||||
|
const WORLD_WIDTH := 1180.0
|
||||||
|
const BOOK_WIDTH := 740.0
|
||||||
|
|
||||||
|
var service: DmService = null # injection seam; real HttpTransport built if null
|
||||||
|
|
||||||
|
var book: NarrationBook
|
||||||
|
var dock: SystemDock
|
||||||
|
var _state: ShellState
|
||||||
|
var _log: CanonLog
|
||||||
|
var _http: HTTPRequest
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
theme = load(GAME_THEME)
|
||||||
|
# Root Control is not sized by full-rect anchors under F6 — size explicitly,
|
||||||
|
# and no anchors_preset on the .tscn root (M3-a collapse bug).
|
||||||
|
_fit_to_viewport()
|
||||||
|
get_viewport().size_changed.connect(_fit_to_viewport)
|
||||||
|
|
||||||
|
_state = ShellState.seed()
|
||||||
|
_log = _build_seed_log()
|
||||||
|
_build_layout()
|
||||||
|
|
||||||
|
if service == null:
|
||||||
|
_http = HTTPRequest.new()
|
||||||
|
add_child(_http)
|
||||||
|
service = DmService.new(HttpTransport.new(_http), FallbackLibrary.new())
|
||||||
|
|
||||||
|
book.setup(service, _log, _state)
|
||||||
|
dock.setup(_state)
|
||||||
|
dock.screen_requested.connect(_on_screen_requested)
|
||||||
|
book.narrate() # initial narration; async, fire-and-forget
|
||||||
|
|
||||||
|
|
||||||
|
func _fit_to_viewport() -> void:
|
||||||
|
size = get_viewport_rect().size
|
||||||
|
|
||||||
|
|
||||||
|
func _build_layout() -> void:
|
||||||
|
var split := HBoxContainer.new()
|
||||||
|
split.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
split.add_theme_constant_override("separation", 0)
|
||||||
|
add_child(split)
|
||||||
|
|
||||||
|
var world := _build_world()
|
||||||
|
world.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
split.add_child(world)
|
||||||
|
|
||||||
|
book = load(NARRATION_BOOK).instantiate()
|
||||||
|
book.custom_minimum_size = Vector2(BOOK_WIDTH, 0)
|
||||||
|
var book_panel := PanelContainer.new()
|
||||||
|
book_panel.theme_type_variation = ThemeKeys.PARCHMENT_CARD
|
||||||
|
book_panel.custom_minimum_size = Vector2(BOOK_WIDTH, 0)
|
||||||
|
book_panel.add_child(book)
|
||||||
|
split.add_child(book_panel)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_world() -> Control:
|
||||||
|
var world := Control.new()
|
||||||
|
world.custom_minimum_size = Vector2(WORLD_WIDTH, 0)
|
||||||
|
world.clip_contents = true
|
||||||
|
|
||||||
|
var bay = load(DARK_BAY).instantiate()
|
||||||
|
bay.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
|
world.add_child(bay)
|
||||||
|
|
||||||
|
var caption := Label.new()
|
||||||
|
caption.theme_type_variation = ThemeKeys.MONO
|
||||||
|
caption.text = "ISOMETRIC WORLD VIEW"
|
||||||
|
caption.set_anchors_preset(Control.PRESET_CENTER)
|
||||||
|
world.add_child(caption)
|
||||||
|
|
||||||
|
var rail := _build_turn_rail()
|
||||||
|
rail.position = Vector2(28, 28)
|
||||||
|
world.add_child(rail)
|
||||||
|
|
||||||
|
var minimap := PanelContainer.new()
|
||||||
|
minimap.theme_type_variation = ThemeKeys.DARK_PANEL
|
||||||
|
minimap.custom_minimum_size = Vector2(200, 200)
|
||||||
|
minimap.set_anchors_preset(Control.PRESET_TOP_RIGHT)
|
||||||
|
minimap.position = Vector2(-228, 28)
|
||||||
|
var minimap_label := Label.new()
|
||||||
|
minimap_label.theme_type_variation = ThemeKeys.MONO
|
||||||
|
minimap_label.text = "MINIMAP"
|
||||||
|
minimap.add_child(minimap_label)
|
||||||
|
world.add_child(minimap)
|
||||||
|
|
||||||
|
dock = load(SYSTEM_DOCK).instantiate()
|
||||||
|
dock.set_anchors_preset(Control.PRESET_CENTER_RIGHT)
|
||||||
|
dock.position = Vector2(-260, 0)
|
||||||
|
world.add_child(dock)
|
||||||
|
|
||||||
|
var command_bar := _build_command_bar()
|
||||||
|
command_bar.set_anchors_preset(Control.PRESET_BOTTOM_WIDE)
|
||||||
|
command_bar.position = Vector2(0, -150)
|
||||||
|
world.add_child(command_bar)
|
||||||
|
|
||||||
|
return world
|
||||||
|
|
||||||
|
|
||||||
|
func _build_turn_rail() -> Control:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.theme_type_variation = ThemeKeys.DARK_PANEL
|
||||||
|
var col := VBoxContainer.new()
|
||||||
|
col.add_theme_constant_override("separation", 8)
|
||||||
|
panel.add_child(col)
|
||||||
|
var title := Label.new()
|
||||||
|
title.theme_type_variation = ThemeKeys.MONO
|
||||||
|
title.text = "TURN ORDER"
|
||||||
|
col.add_child(title)
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 12)
|
||||||
|
col.add_child(row)
|
||||||
|
for e in _state.turn_order:
|
||||||
|
var token := Label.new()
|
||||||
|
token.theme_type_variation = ThemeKeys.ACCENT
|
||||||
|
token.text = "✕" if e.is_downed else e.initials
|
||||||
|
token.add_theme_color_override("font_color", _side_color(e))
|
||||||
|
row.add_child(token)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _side_color(e: TurnEntry) -> Color:
|
||||||
|
if e.is_downed:
|
||||||
|
return Palette.BLOOD
|
||||||
|
if e.is_active:
|
||||||
|
return Palette.GOLD
|
||||||
|
match e.side:
|
||||||
|
&"enemy": return Palette.BLOOD_BRIGHT
|
||||||
|
&"ally": return Palette.STEEL
|
||||||
|
_: return Palette.CREAM
|
||||||
|
|
||||||
|
|
||||||
|
func _build_command_bar() -> Control:
|
||||||
|
var bar := HBoxContainer.new()
|
||||||
|
bar.add_theme_constant_override("separation", 24)
|
||||||
|
bar.custom_minimum_size = Vector2(0, 150)
|
||||||
|
|
||||||
|
var vitals := VBoxContainer.new()
|
||||||
|
var hp := Label.new()
|
||||||
|
hp.theme_type_variation = ThemeKeys.MONO
|
||||||
|
hp.text = "HP %d / %d" % [_state.vitals["hp"], _state.vitals["hp_max"]]
|
||||||
|
var mp := Label.new()
|
||||||
|
mp.theme_type_variation = ThemeKeys.MONO
|
||||||
|
mp.text = "MP %d / %d" % [_state.vitals["mp"], _state.vitals["mp_max"]]
|
||||||
|
var gold := Label.new()
|
||||||
|
gold.theme_type_variation = ThemeKeys.MONO
|
||||||
|
gold.add_theme_color_override("font_color", Palette.GOLD_BRIGHT)
|
||||||
|
gold.text = "◈ %d gp" % _state.vitals["gold"]
|
||||||
|
for l in [hp, mp, gold]:
|
||||||
|
vitals.add_child(l)
|
||||||
|
bar.add_child(vitals)
|
||||||
|
|
||||||
|
var slots := HBoxContainer.new()
|
||||||
|
slots.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
slots.add_theme_constant_override("separation", 9)
|
||||||
|
for c in _state.consumables:
|
||||||
|
var slot := PanelContainer.new()
|
||||||
|
slot.theme_type_variation = ThemeKeys.ITEM_TILE if c["label"] != "" else ThemeKeys.ITEM_TILE_EMPTY
|
||||||
|
slot.custom_minimum_size = Vector2(62, 62)
|
||||||
|
var name_label := Label.new()
|
||||||
|
name_label.theme_type_variation = ThemeKeys.MONO
|
||||||
|
name_label.text = c["label"]
|
||||||
|
slot.add_child(name_label)
|
||||||
|
slots.add_child(slot)
|
||||||
|
bar.add_child(slots)
|
||||||
|
|
||||||
|
var end_turn := Button.new()
|
||||||
|
end_turn.theme_type_variation = ThemeKeys.PRIMARY_CTA
|
||||||
|
end_turn.text = "END TURN"
|
||||||
|
bar.add_child(end_turn)
|
||||||
|
|
||||||
|
return bar
|
||||||
|
|
||||||
|
|
||||||
|
func _on_screen_requested(_id: StringName) -> void:
|
||||||
|
pass # routing seam — the dock's screens land in later milestones (§17)
|
||||||
|
|
||||||
|
|
||||||
|
func _build_seed_log() -> CanonLog:
|
||||||
|
# Code owns the canon log (§11). Hand-built until character creation (M4) and
|
||||||
|
# save/load (M9) construct the real one — mirrors narrate_harness. No Luck
|
||||||
|
# number is rendered anywhere (§7); the descriptor is prose-only.
|
||||||
|
var log := CanonLog.new()
|
||||||
|
log.player = LogPlayer.new("Vexcca", "sellsword", "Fortune spits on you")
|
||||||
|
log.set_location("lower_ward", "the Lower Ward")
|
||||||
|
log.party.append(PartyMember.new("brannoc_thane", "Brannoc Thane", 40))
|
||||||
|
log.party.append(PartyMember.new("cadwyn_vell", "Cadwyn Vell", 15))
|
||||||
|
log.push_event("Followed the beastfolk fence into the Lower Ward after dark")
|
||||||
|
log.push_event("The sewer bite on Vexcca's left arm has begun to turn")
|
||||||
|
log.add_fact("the fence deals out of the Lower Ward")
|
||||||
|
log.add_fact("Vexcca carries an infected bite on the left arm")
|
||||||
|
log.active_quests.append(Quest.new("the_fences_price", "The Fence's Price", "active", "Settle with the beastfolk fence"))
|
||||||
|
log.add_humiliation("h_0001", "haggled badly and got a wrist crushed for it", 6, 4)
|
||||||
|
return log
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `client/scenes/shell/MainWindowShell.tscn` (note: **no `anchors_preset` on the root** — the viewport-fit gotcha):
|
||||||
|
|
||||||
|
```
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/ui/shell/main_window_shell.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="MainWindowShell" type="Control"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh -gselect=test_main_window_shell.gd`
|
||||||
|
Expected: PASS — 3 passing tests, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the full suite (no regressions)**
|
||||||
|
|
||||||
|
Run: `cd client && ./run_tests.sh`
|
||||||
|
Expected: PASS — all prior tests (110+) plus the new shell tests, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/scripts/ui/shell/main_window_shell.gd client/scenes/shell/MainWindowShell.tscn client/tests/unit/test_main_window_shell.gd client/scripts/ui/shell/main_window_shell.gd.uid client/scenes/shell/MainWindowShell.tscn.uid client/tests/unit/test_main_window_shell.gd.uid
|
||||||
|
git commit -m "feat(shell): MainWindowShell assembles the frame and wires the DM loop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Visual gate — human F6 run
|
||||||
|
|
||||||
|
**This task is not automatable.** Headless GUT cannot prove rendering (the M3-a lesson: a root Control collapsed to 0×0 and every test still passed). A human must eyeball the scene.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Open the scene in the Godot editor and run it**
|
||||||
|
|
||||||
|
Open `client/scenes/shell/MainWindowShell.tscn`, press **F6**. For the live DM prose, have the proxy + Ollama up (see `client/docs/README.md` live-smoke steps); without them the book must degrade to the authored fallback line (§13), not error.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the checklist by eye**
|
||||||
|
|
||||||
|
- The two-panel split fills 1920×1080 — world on the left (dark stripe + vignette), parchment book on the right. **Not** collapsed / blank.
|
||||||
|
- Turn-order rail (top-left), minimap slot (top-right), command bar (bottom: HP/MP/gold, 8 consumable slots, END TURN).
|
||||||
|
- The gold dock handle slides the dock in/out when clicked; the arrow flips ‹ / ›.
|
||||||
|
- The book shows the header (`ROUND 4 · THE LOWER WARD`), then either live DM prose or the fallback line; the considering line rotates while a call is in flight.
|
||||||
|
- Pressing **▸ The Master continues…** refires narration (or degrades cleanly if the proxy is down).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Report the outcome**
|
||||||
|
|
||||||
|
If anything is collapsed, mis-coloured (hardcoded hex would show here), or errors, fix and re-run before the branch is considered done. When it looks right, the milestone is eyeball-confirmed and ready to merge to `dev`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Iso world slot (art slot) → Task 4 `_build_world` (DarkBay + caption). ✓
|
||||||
|
- DM narration book wired to `/dm/narrate` + considering-state → Task 3. ✓
|
||||||
|
- Turn-order rail → Task 4 `_build_turn_rail` from `ShellState.turn_order`. ✓
|
||||||
|
- Minimap slot → Task 4 (placeholder Panel). ✓
|
||||||
|
- Slide-in system dock (6 toggles incl. Spellbook, inert) → Task 2. ✓
|
||||||
|
- Command bar (HP/MP/gold, consumables, End Turn) → Task 4 `_build_command_bar`. ✓
|
||||||
|
- Thin tested `ShellState` → Task 1. ✓
|
||||||
|
- Refire control, prose-only, inert choices/free-text → Task 3. ✓
|
||||||
|
- Degrade on non-200 (§13) → reused `DmService`, asserted in Task 3. ✓
|
||||||
|
- Fact-harvest (§11) → Task 3 `narrate()`, asserted. ✓
|
||||||
|
- Viewport-fit gotcha → Task 4, asserted in test + Task 5 eyeball. ✓
|
||||||
|
- Visual gate → Task 5. ✓
|
||||||
|
|
||||||
|
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to". Every code step shows full code. ✓
|
||||||
|
|
||||||
|
**Type consistency:** `ShellState.seed()`, `TurnEntry.new(...)`, `SystemDock.setup`/`_on_handle_pressed`/`_screen_buttons`/`screen_requested`, `NarrationBook.setup`/`narrate`/`show_prose`/`last_degraded`/`_prose`, `MainWindowShell.service`/`book`/`dock`/`_log` — names match across the tasks that reference them. `NarrateResult.display_text`/`facts`/`degraded`, `DmResponse.ok`/`failed`, `FakeTransport.set_response`, `CanonLog.add_fact`/`established_facts`/`set_location`/`push_event`/`active_quests`/`add_humiliation`, `LogPlayer.new`/`PartyMember.new`/`Quest.new` — all match the existing code read during planning. ✓
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Main Window shell (2a) — design
|
||||||
|
|
||||||
|
**Milestone:** M3-b (roadmap "Main Window shell (2a)").
|
||||||
|
**Charter:** §2 (code owns state, AI owns text), §16 (mockups are the UI bible), §13 (degraded DM, never an error), §11 (canon log).
|
||||||
|
**Mockup:** `mockups/Main Window + Wireframes.dc.html`, option **2a** (the four low-fi wireframes below it are ignored).
|
||||||
|
**Branch:** `feature/main-window-shell` off `dev`.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
The exploration HUD — the frame every other screen lives in. A two-panel "tabletop split": an isometric world view (art slot) on the left, a permanent DM "narration book" on the right wired to the **proven** `/dm/narrate` loop. Around the world view: a turn-order rail, a minimap slot, a slide-in system dock, and a bottom command bar (vitals/gold, quick consumables, End Turn).
|
||||||
|
|
||||||
|
This milestone builds the **frame** and its **one live seam** (the DM narration panel). Everything else is faithful placeholder until the milestones that give it meaning (combat, inventory, the individual screens).
|
||||||
|
|
||||||
|
## 2. Scope decisions
|
||||||
|
|
||||||
|
Settled during brainstorming:
|
||||||
|
|
||||||
|
- **DM wire = prose only, with a refire control.** Narrate fires once on scene load to fill the book (considering-state + authored fallback + fact-harvest). One in-world refire affordance (`▸ The Master continues…`) refires it. The numbered response choices and the "describe your own action" free-text line are rendered faithfully but **inert** — there is no action system yet (combat/dialogue are later).
|
||||||
|
- **HUD readouts come from a thin, tested `ShellState` model**, not hardcoded scene values. Seed values now; later systems (combat, inventory) become its writers. This is the §2 state seam and the testable core.
|
||||||
|
- **System dock buttons are inert.** They emit `screen_requested(id)` (the routing seam) which is a no-op this milestone; the individual screens land later.
|
||||||
|
- **Dock slide animation is live** — it is the dock's visual identity and cheap.
|
||||||
|
- **Keybind navigation (I/C/J/K/M/P) is deferred** — a shortcut belongs with the screen it opens.
|
||||||
|
- **Dock has 6 items** (Inventory / Character / Quest Log / Spellbook / World Map / Party) — faithful to mock 2a. The brief listed 5 (no Spellbook); kept all 6 since the buttons are inert and it costs nothing. Flagged as a reconcile point — Spellbook is not one of the 11 mock screens.
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
House pattern: **pure, tested cores behind thin scene shims** — the same shape as `DmService`, `ConsideringPhrases`, `MoveValidator` (pure logic unit-tested; the scene/tree wiring is the untested shim, eyeball-gated). Units, each with one purpose:
|
||||||
|
|
||||||
|
| Unit | Kind | §2 | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ShellState` | `RefCounted`, **tested** | state | The HUD data the shell owns: `vitals {hp, hp_max, mp, mp_max, gold}`, `turn_order[]`, `consumables[]`, `round_label`, `location_label`, `dock_open`. Getters + `toggle_dock()`. Seeded now; later systems write it. |
|
||||||
|
| `TurnEntry` | `RefCounted` (or a typed dict inside `ShellState`), **tested** | state | One turn-order combatant: `initials`, `initiative`, `is_active`, `is_downed`, `side` (`you`/`ally`/`enemy`). |
|
||||||
|
| `SystemDock` | Control scene + script, thin shim | state | The 6 toggle buttons + gold handle. Slide+opacity `Tween`, arrow flip. Emits `screen_requested(id: StringName)` (inert). Toggle drives `ShellState.dock_open`. |
|
||||||
|
| `NarrationBook` | Control scene + script, thin shim | text | Right parchment panel. DM header (round · location), prose `RichTextLabel`, placeholder response-choices, inert free-text line, one refire affordance. Consumes a `NarrateResult`; injectable service seam so `FakeTransport` drives it in tests. |
|
||||||
|
| `MainWindowShell` | Control scene root, thin shim | — | The two-panel split (1180 world / 740 book). Builds the world side inline (DarkBay iso slot + caption, turn rail from state, minimap slot, mounts `SystemDock`, command bar). Constructs `DmService` + `ConsideringIndicator` and owns the narrate loop. Handles viewport-fit. |
|
||||||
|
|
||||||
|
### Reused, not rebuilt
|
||||||
|
|
||||||
|
- **Theme:** `client/assets/theme/game_theme.tres`, type-variation names from `ThemeKeys`, colours from `Palette`. Surfaces `ParchmentPanel` / `DarkBay` / `VignetteOverlay` (`client/scenes/theme/surfaces/`).
|
||||||
|
- **DM loop:** `DmService.narrate(canon_log) -> NarrateResult{display_text, facts, degraded}`, via `HttpTransport(HTTPRequest)` + `FallbackLibrary`. `DmTransport` is the injectable seam; `FakeTransport` (test double) already exists.
|
||||||
|
- **Considering-state:** `ConsideringIndicator.start(label)` / `.stop()` rotates authored phrases at 3.0s.
|
||||||
|
- **Seed canon log:** hand-built like `narrate_harness.gd`'s `_build_scene_log()` (`CanonLog` + `LogPlayer` + `PartyMember` + `Quest` + facts/humiliations), until character-creation (M4) and save/load (M9) construct the real one.
|
||||||
|
|
||||||
|
### The viewport-fit gotcha (from M3-a)
|
||||||
|
|
||||||
|
A root `Control` run directly (F6) is **not** reliably sized by full-rect anchors — it collapses to `(0,0)` and clips everything. `MainWindowShell._ready()` must set `size = get_viewport_rect().size` explicitly (no full-rect anchors on the root) and reconnect on `get_viewport().size_changed`, exactly as `theme_showcase.gd` does. The scene is authored at 1920×1080; display is already `canvas_items` stretch @ 1920×1080, window override 1600×900.
|
||||||
|
|
||||||
|
## 4. Data flow
|
||||||
|
|
||||||
|
**Load (`MainWindowShell._ready()`):**
|
||||||
|
1. `theme = load(game_theme.tres)`; fit to viewport; connect `size_changed`.
|
||||||
|
2. Build seed `ShellState` and seed `CanonLog`.
|
||||||
|
3. Build the world side (from `ShellState`) and mount `NarrationBook` + `SystemDock`.
|
||||||
|
4. Construct `DmService(HttpTransport(HTTPRequest), FallbackLibrary)` and `ConsideringIndicator`.
|
||||||
|
5. Fire the initial narrate (below).
|
||||||
|
|
||||||
|
**Narrate / refire:**
|
||||||
|
1. Disable the refire affordance.
|
||||||
|
2. `indicator.start(status_label)`.
|
||||||
|
3. `var r := await service.narrate(seed_log)`.
|
||||||
|
4. `indicator.stop()`.
|
||||||
|
5. `book.show_prose(r)` — set the `RichTextLabel`, surface `r.degraded` subtly.
|
||||||
|
6. `for f in r.facts: seed_log.add_fact(f)` (§11 — the caller applies the state write, explicit).
|
||||||
|
7. Re-enable the affordance.
|
||||||
|
|
||||||
|
Any non-200 → `DmService` already returns the authored fallback (§13); the 35s `HttpTransport` timeout bounds a hang. **No new failure surface.**
|
||||||
|
|
||||||
|
**Dock toggle:** handle pressed → `ShellState.toggle_dock()` → `SystemDock` Tweens slide/opacity and flips the handle arrow.
|
||||||
|
|
||||||
|
## 5. §2 ledger
|
||||||
|
|
||||||
|
- `ShellState`, `dock_open`, HUD numbers, turn order, consumable slots — **state**, client-owned.
|
||||||
|
- Narration prose — **text**, consumed from `/dm/narrate`, never a source of truth; only harvested `[FACT]` tags become state (§11).
|
||||||
|
- `screen_requested` routing, response choices, free-text line — inert seams; no state written, no text consumed, this milestone.
|
||||||
|
|
||||||
|
Nothing in the shell lets an AI response set persistent state directly.
|
||||||
|
|
||||||
|
## 6. Testing
|
||||||
|
|
||||||
|
TDD via GUT (`cd client && ./run_tests.sh`; `.gutconfig` promotes engine errors to failures — load defensively). New `class_name`s need `godot --headless --import` before GUT sees them.
|
||||||
|
|
||||||
|
- **`ShellState`** — seed shape; `toggle_dock()` flips `dock_open`; turn-order and consumable entries have the expected fields/values.
|
||||||
|
- **`TurnEntry`** — field round-trip; `side`/`is_active`/`is_downed` flags.
|
||||||
|
- **`SystemDock`** — emits `screen_requested` with the correct id per button; toggling updates state (signal + state assertions, no render needed).
|
||||||
|
- **`NarrationBook`** — given a `NarrateResult`, sets the prose text and surfaces `degraded`; the refire affordance invokes the injected service (drive with `FakeTransport`, assert prose updates + facts flow out).
|
||||||
|
- **`MainWindowShell`** — node-presence smoke (both panels + dock mount); viewport-fit yields non-zero `size`.
|
||||||
|
|
||||||
|
**Visual gate (required):** a human F6 run of the shell scene. Headless tests cannot prove rendering — this catches layout/collapse regressions the unit tests can't (the M3-a lesson).
|
||||||
|
|
||||||
|
## 7. Out of scope (this milestone)
|
||||||
|
|
||||||
|
- Any of the individual screens the dock opens (Inventory, Character, Quest, Map, Party, Spellbook) — inert buttons only.
|
||||||
|
- A real action system behind the response choices / free-text line — combat (M5) and dialogue (M6).
|
||||||
|
- Real HUD writers (combat vitals, inventory consumables) — seed values only.
|
||||||
|
- Keybind navigation, real isometric render, real minimap, real portraits — art slots / later.
|
||||||
|
- Save/load of shell state — M9.
|
||||||
Reference in New Issue
Block a user