diff --git a/client/scenes/shell/MainWindowShell.tscn b/client/scenes/shell/MainWindowShell.tscn new file mode 100644 index 0000000..5ff2349 --- /dev/null +++ b/client/scenes/shell/MainWindowShell.tscn @@ -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") diff --git a/client/scripts/ui/shell/main_window_shell.gd b/client/scripts/ui/shell/main_window_shell.gd new file mode 100644 index 0000000..ebacf2c --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd @@ -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 diff --git a/client/scripts/ui/shell/main_window_shell.gd.uid b/client/scripts/ui/shell/main_window_shell.gd.uid new file mode 100644 index 0000000..32a41b2 --- /dev/null +++ b/client/scripts/ui/shell/main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://bobihkpvsax2u diff --git a/client/tests/unit/test_main_window_shell.gd b/client/tests/unit/test_main_window_shell.gd new file mode 100644 index 0000000..44413ab --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd @@ -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") diff --git a/client/tests/unit/test_main_window_shell.gd.uid b/client/tests/unit/test_main_window_shell.gd.uid new file mode 100644 index 0000000..9547284 --- /dev/null +++ b/client/tests/unit/test_main_window_shell.gd.uid @@ -0,0 +1 @@ +uid://dbd4cq1awo4su