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:
6
client/scenes/shell/MainWindowShell.tscn
Normal file
6
client/scenes/shell/MainWindowShell.tscn
Normal 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")
|
||||
212
client/scripts/ui/shell/main_window_shell.gd
Normal file
212
client/scripts/ui/shell/main_window_shell.gd
Normal 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
|
||||
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
|
||||
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
|
||||
Reference in New Issue
Block a user