feat(shell): MainWindowShell assembles the frame and wires the DM loop

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 09:16:14 -05:00
parent 5f1ebdf840
commit 578b3e86c9
5 changed files with 257 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
[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")

View File

@@ -0,0 +1,212 @@
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
# 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 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

View File

@@ -0,0 +1 @@
uid://bobihkpvsax2u

View 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")

View File

@@ -0,0 +1 @@
uid://dbd4cq1awo4su