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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
[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/JetBrainsMono-VariableFont_wght.ttf" id="2_605w4"]
|
||||
[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/ArchitectsDaughter-Regular.ttf" id="1_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_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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -22,7 +23,7 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -38,7 +39,7 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -54,7 +55,7 @@ corner_radius_top_right = 3
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -70,7 +71,57 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -86,7 +137,7 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -103,7 +154,73 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -119,7 +236,7 @@ corner_radius_top_right = 6
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -135,7 +252,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -151,7 +268,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -167,7 +284,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -183,7 +300,7 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -199,9 +316,9 @@ corner_radius_top_right = 4
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -217,7 +334,7 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -233,7 +350,7 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -249,9 +366,9 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -267,7 +384,7 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -283,7 +400,7 @@ corner_radius_top_right = 14
|
||||
corner_radius_bottom_right = 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_top = 6.0
|
||||
content_margin_right = 12.0
|
||||
@@ -300,54 +417,72 @@ corner_radius_bottom_right = 14
|
||||
corner_radius_bottom_left = 14
|
||||
|
||||
[resource]
|
||||
default_font = ExtResource("3_4lwr2")
|
||||
default_font = ExtResource("3_eckwq")
|
||||
default_font_size = 18
|
||||
Accent/base_type = &"Label"
|
||||
Accent/colors/font_color = Color(0.56078434, 0.41568628, 0.16470589, 1)
|
||||
Accent/font_sizes/font_size = 24
|
||||
Accent/fonts/font = ExtResource("1_cu4xe")
|
||||
Accent/fonts/font = ExtResource("1_605w4")
|
||||
Chip/base_type = &"Button"
|
||||
Chip/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
Chip/font_sizes/font_size = 11
|
||||
Chip/fonts/font = ExtResource("2_605w4")
|
||||
Chip/styles/focus = SubResource("StyleBoxEmpty_eckwq")
|
||||
Chip/styles/hover = SubResource("StyleBoxFlat_i0b23")
|
||||
Chip/styles/normal = SubResource("StyleBoxFlat_cqbvn")
|
||||
Chip/styles/pressed = SubResource("StyleBoxFlat_q5gpk")
|
||||
Chip/fonts/font = ExtResource("2_4lwr2")
|
||||
Chip/styles/focus = SubResource("StyleBoxEmpty_cqbvn")
|
||||
Chip/styles/hover = SubResource("StyleBoxFlat_q5gpk")
|
||||
Chip/styles/normal = SubResource("StyleBoxFlat_bbpmc")
|
||||
Chip/styles/pressed = SubResource("StyleBoxFlat_73lme")
|
||||
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/colors/font_color = Color(0.22745098, 0.18431373, 0.10980392, 1)
|
||||
Heading/font_sizes/font_size = 34
|
||||
Heading/fonts/font = ExtResource("3_4lwr2")
|
||||
Heading/fonts/font = ExtResource("3_eckwq")
|
||||
ItemTile/base_type = &"PanelContainer"
|
||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_73lme")
|
||||
ItemTile/styles/panel = SubResource("StyleBoxFlat_0fjdt")
|
||||
ItemTileEmpty/base_type = &"PanelContainer"
|
||||
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_js3tr")
|
||||
ItemTileEmpty/styles/panel = SubResource("StyleBoxFlat_ns3kg")
|
||||
Mono/base_type = &"Label"
|
||||
Mono/colors/font_color = Color(0.6039216, 0.56078434, 0.47058824, 1)
|
||||
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/styles/panel = SubResource("StyleBoxFlat_ube3r")
|
||||
ParchmentCard/styles/panel = SubResource("StyleBoxFlat_oi067")
|
||||
ParchmentInset/base_type = &"PanelContainer"
|
||||
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_2tooq")
|
||||
ParchmentInset/styles/panel = SubResource("StyleBoxFlat_ukblj")
|
||||
PrimaryCTA/base_type = &"Button"
|
||||
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/styles/disabled = SubResource("StyleBoxFlat_4kten")
|
||||
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_p6wtm")
|
||||
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_0fjdt")
|
||||
PrimaryCTA/styles/pressed = SubResource("StyleBoxFlat_ns3kg")
|
||||
PrimaryCTA/styles/disabled = SubResource("StyleBoxFlat_utt73")
|
||||
PrimaryCTA/styles/hover = SubResource("StyleBoxFlat_5u8fh")
|
||||
PrimaryCTA/styles/normal = SubResource("StyleBoxFlat_p0qbh")
|
||||
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/colors/font_color = Color(0.41568628, 0.3529412, 0.22745098, 1)
|
||||
Tab/styles/focus = SubResource("StyleBoxEmpty_jly1r")
|
||||
Tab/styles/hover = SubResource("StyleBoxFlat_lw8lk")
|
||||
Tab/styles/normal = SubResource("StyleBoxFlat_nh0e7")
|
||||
Tab/styles/pressed = SubResource("StyleBoxFlat_dtr07")
|
||||
Tab/styles/focus = SubResource("StyleBoxEmpty_txxuc")
|
||||
Tab/styles/hover = SubResource("StyleBoxFlat_7d6ug")
|
||||
Tab/styles/normal = SubResource("StyleBoxFlat_najyk")
|
||||
Tab/styles/pressed = SubResource("StyleBoxFlat_jtb1k")
|
||||
TabActive/base_type = &"Button"
|
||||
TabActive/colors/font_color = Color(0.9411765, 0.9019608, 0.8156863, 1)
|
||||
TabActive/styles/focus = SubResource("StyleBoxEmpty_nmowx")
|
||||
TabActive/styles/hover = SubResource("StyleBoxFlat_oi067")
|
||||
TabActive/styles/normal = SubResource("StyleBoxFlat_ukblj")
|
||||
TabActive/styles/pressed = SubResource("StyleBoxFlat_utt73")
|
||||
TabActive/styles/focus = SubResource("StyleBoxEmpty_wmbj4")
|
||||
TabActive/styles/hover = SubResource("StyleBoxFlat_xdylb")
|
||||
TabActive/styles/normal = SubResource("StyleBoxFlat_pjnll")
|
||||
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.
|
||||
|
||||
## 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)
|
||||
|
||||
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 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 MONO := "res://assets/theme/fonts/JetBrainsMono-VariableFont_wght.ttf"
|
||||
|
||||
@@ -33,8 +34,11 @@ func _init() -> void:
|
||||
static func build_theme() -> Theme:
|
||||
var theme := Theme.new()
|
||||
_fonts(theme)
|
||||
_rich_text(theme)
|
||||
_primary_cta(theme)
|
||||
_tabs(theme)
|
||||
_dock_button(theme)
|
||||
_parchment_button(theme)
|
||||
_chip(theme)
|
||||
_tiles(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)
|
||||
|
||||
|
||||
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:
|
||||
var k := ThemeKeys.PRIMARY_CTA
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
# Base chip; the semantic colour (red/gold/grey) is applied per-use by script.
|
||||
var k := ThemeKeys.CHIP
|
||||
|
||||
@@ -9,6 +9,8 @@ const PRIMARY_CTA := &"PrimaryCTA"
|
||||
const TAB := &"Tab"
|
||||
const TAB_ACTIVE := &"TabActive"
|
||||
const CHIP := &"Chip"
|
||||
const DOCK_BUTTON := &"DockButton"
|
||||
const PARCHMENT_BUTTON := &"ParchmentButton"
|
||||
const ITEM_TILE := &"ItemTile"
|
||||
const ITEM_TILE_EMPTY := &"ItemTileEmpty"
|
||||
const PARCHMENT_CARD := &"ParchmentCard"
|
||||
@@ -28,6 +30,8 @@ const ALL := {
|
||||
TAB: "Button",
|
||||
TAB_ACTIVE: "Button",
|
||||
CHIP: "Button",
|
||||
DOCK_BUTTON: "Button",
|
||||
PARCHMENT_BUTTON: "Button",
|
||||
ITEM_TILE: "PanelContainer",
|
||||
ITEM_TILE_EMPTY: "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
|
||||
|
||||
- _none yet_
|
||||
- [0001 — UI screens are editor-authored scenes, not code-built](0001-editor-first-ui-scenes.md) (accepted)
|
||||
|
||||
<!-- Template:
|
||||
## 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.
|
||||
|
||||
### 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.*
|
||||
- ○ **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.*
|
||||
- ○ **Title screen** — new game / continue / load / settings; the entry point. *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||
- ✅ **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/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. Author it editor-first (ADR 0001). *§2: n/a · depends on the Theme · goal: first impression, into the world.*
|
||||
|
||||
### 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.*
|
||||
|
||||
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