diff --git a/client/project.godot b/client/project.godot index 7e4c7ca..0ea126e 100644 --- a/client/project.godot +++ b/client/project.godot @@ -13,6 +13,7 @@ config_version=5 config/name="coc-rpg" config/version="0.01-alpha" config/description="AI-driven single-player party RPG. Code owns state. AI owns text." +run/main_scene="res://scenes/title/TitleScreen.tscn" config/features=PackedStringArray("4.7") [display] diff --git a/client/scenes/title/TitleScreen.tscn b/client/scenes/title/TitleScreen.tscn new file mode 100644 index 0000000..0ee32cc --- /dev/null +++ b/client/scenes/title/TitleScreen.tscn @@ -0,0 +1,228 @@ +[gd_scene load_steps=4 format=3] + +[ext_resource type="Script" path="res://scripts/ui/title/title_screen.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/VignetteOverlay.tscn" id="3"] + +[node name="TitleScreen" type="Control"] +layout_mode = 3 +anchors_preset = 0 +offset_right = 1920.0 +offset_bottom = 1080.0 +theme = ExtResource("2") +script = ExtResource("1") + +[node name="Background" type="ColorRect" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="KeyArt" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 6 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -520.0 +offset_top = -20.0 +offset_right = -120.0 +offset_bottom = 20.0 +grow_horizontal = 0 +grow_vertical = 2 +theme_type_variation = &"Mono" +text = "KEY ART DROPS IN HERE" +horizontal_alignment = 1 + +[node name="Vignette" parent="." instance=ExtResource("3")] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Content" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 4 +anchor_top = 0.5 +anchor_bottom = 0.5 +offset_left = 120.0 +offset_top = -260.0 +offset_right = 900.0 +offset_bottom = 260.0 +grow_vertical = 2 +theme_override_constants/separation = 10 + +[node name="Kicker" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleKicker" +text = "A DARK FANTASY RPG" + +[node name="Logo" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"TitleLogo" +text = "CODE OF +CONQUEST" + +[node name="Rule" type="ColorRect" parent="Content"] +custom_minimum_size = Vector2(220, 2) +layout_mode = 2 + +[node name="Tagline" type="Label" parent="Content"] +layout_mode = 2 +theme_type_variation = &"Accent" +text = "The world has real problems, and it does not care about you." + +[node name="Menu" type="VBoxContainer" parent="Content"] +layout_mode = 2 +theme_override_constants/separation = 5 + +[node name="NewGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"new_game" + +[node name="Marker" type="Label" parent="Content/Menu/NewGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/NewGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/NewGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "New Game" + +[node name="Continue" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"continue" + +[node name="Marker" type="Label" parent="Content/Menu/Continue"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Continue"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Continue" + +[node name="Sub" type="Label" parent="Content/Menu/Continue/Body"] +layout_mode = 2 +theme_type_variation = &"Mono" +text = "CHAPTER II · VEXCCA · 14:22:07" + +[node name="LoadGame" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"load_game" + +[node name="Marker" type="Label" parent="Content/Menu/LoadGame"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/LoadGame"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/LoadGame/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Load Game" + +[node name="Settings" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"settings" + +[node name="Marker" type="Label" parent="Content/Menu/Settings"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Settings"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Settings/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Settings" + +[node name="Credits" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"credits" + +[node name="Marker" type="Label" parent="Content/Menu/Credits"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Credits"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Credits/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Credits" + +[node name="Quit" type="HBoxContainer" parent="Content/Menu"] +layout_mode = 2 +mouse_filter = 0 +theme_override_constants/separation = 16 +metadata/action = &"quit" + +[node name="Marker" type="Label" parent="Content/Menu/Quit"] +layout_mode = 2 +theme_override_font_sizes/font_size = 15 +text = "◆" + +[node name="Body" type="VBoxContainer" parent="Content/Menu/Quit"] +layout_mode = 2 + +[node name="Label" type="Label" parent="Content/Menu/Quit/Body"] +layout_mode = 2 +theme_override_font_sizes/font_size = 30 +text = "Quit" + +[node name="FooterLeft" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 120.0 +offset_top = -60.0 +offset_right = 700.0 +offset_bottom = -32.0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "v0.01-alpha · BUILT IN GODOT 4.7 · © 2026" + +[node name="FooterRight" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 3 +anchor_left = 1.0 +anchor_top = 1.0 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = -520.0 +offset_top = -60.0 +offset_right = -120.0 +offset_bottom = -32.0 +grow_horizontal = 0 +grow_vertical = 0 +theme_type_variation = &"Mono" +text = "[ ↑ ↓ ] CHOOSE [ ↵ ] ENTER" +horizontal_alignment = 2 diff --git a/client/scripts/ui/title/title_screen.gd b/client/scripts/ui/title/title_screen.gd new file mode 100644 index 0000000..0052ce1 --- /dev/null +++ b/client/scripts/ui/title/title_screen.gd @@ -0,0 +1,95 @@ +class_name TitleScreen +extends Control +## The entry-point screen (mock Title Screen). Editor-first (ADR 0001): the whole +## composition lives in TitleScreen.tscn; this script drives selection, input, and +## dispatches the menu. §2: presentation/state — no AI here (the DM enters at the +## shell). The atmosphere (bg shader, glow, embers) is layered on in a later task. + +signal menu_activated(id: StringName) + +const SHELL := "res://scenes/shell/MainWindowShell.tscn" + +var _rows: Array = [] +var _sel: int = 0 + +@onready var _menu: VBoxContainer = $Content/Menu +@onready var _footer_left: Label = $FooterLeft +@onready var _background: ColorRect = $Background +@onready var _rule: ColorRect = $Content/Rule + + +func _ready() -> void: + # Root Control run via F6 is not sized by full-rect anchors — size explicitly. + _fit_to_viewport() + get_viewport().size_changed.connect(_fit_to_viewport) + + _background.color = Palette.STAGE_1 + _rule.color = Palette.GOLD + + _rows = [] + for row in _menu.get_children(): + if row.has_meta("action"): + _rows.append(row) + (row.get_node("Marker") as Label).add_theme_color_override("font_color", Palette.GOLD) + _wire_row_mouse() + + _footer_left.text = Version.new().footer() + menu_activated.connect(_on_menu_activated) + _update_selection() + + +func _fit_to_viewport() -> void: + size = get_viewport_rect().size + + +func _wire_row_mouse() -> void: + for i in range(_rows.size()): + var idx := i # fresh per-iteration binding for the closures + var row: Control = _rows[idx] + row.mouse_entered.connect(func(): + _sel = idx + _update_selection()) + row.gui_input.connect(func(e: InputEvent): + if e is InputEventMouseButton and e.pressed and e.button_index == MOUSE_BUTTON_LEFT: + _activate(idx)) + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("ui_down"): + _move(1) + elif event.is_action_pressed("ui_up"): + _move(-1) + elif event.is_action_pressed("ui_accept"): + _activate(_sel) + + +func _move(delta: int) -> void: + if _rows.is_empty(): + return + _sel = wrapi(_sel + delta, 0, _rows.size()) + _update_selection() + + +func _activate(i: int) -> void: + if i < 0 or i >= _rows.size(): + return + menu_activated.emit(_rows[i].get_meta("action")) + + +func _on_menu_activated(id: StringName) -> void: + match id: + &"new_game": + get_tree().change_scene_to_file(SHELL) + &"quit": + get_tree().quit() + _: + pass # inert placeholder — the screen lands in a later milestone + + +func _update_selection() -> void: + for i in range(_rows.size()): + var row: Control = _rows[i] + var selected := i == _sel + (row.get_node("Marker") as Label).modulate.a = 1.0 if selected else 0.0 + var label := row.get_node("Body/Label") as Label + label.add_theme_color_override("font_color", Palette.CREAM_BRIGHT if selected else Palette.MUTED_MONO) diff --git a/client/scripts/ui/title/title_screen.gd.uid b/client/scripts/ui/title/title_screen.gd.uid new file mode 100644 index 0000000..50947e2 --- /dev/null +++ b/client/scripts/ui/title/title_screen.gd.uid @@ -0,0 +1 @@ +uid://clmbt3dy7bdwb diff --git a/client/tests/unit/test_title_screen.gd b/client/tests/unit/test_title_screen.gd new file mode 100644 index 0000000..2be0fa5 --- /dev/null +++ b/client/tests/unit/test_title_screen.gd @@ -0,0 +1,52 @@ +extends "res://addons/gut/test.gd" + +const SCENE := "res://scenes/title/TitleScreen.tscn" + + +func _title() -> TitleScreen: + var t = load(SCENE).instantiate() + add_child_autofree(t) # runs _ready() + return t + + +func _index_of(t: TitleScreen, action: StringName) -> int: + for i in range(t._rows.size()): + if t._rows[i].get_meta("action") == action: + return i + return -1 + + +func test_six_menu_rows_each_with_an_action(): + var t := _title() + assert_eq(t._rows.size(), 6) + for row in t._rows: + assert_true(row.has_meta("action")) + + +func test_down_moves_selection(): + var t := _title() + assert_eq(t._sel, 0) + t._move(1) + assert_eq(t._sel, 1) + + +func test_up_from_first_wraps_to_last(): + var t := _title() + t._move(-1) + assert_eq(t._sel, t._rows.size() - 1) + + +func test_new_game_and_quit_emit_their_action(): + var t := _title() + # Isolate the signal seam so the test does not actually change scene / quit. + t.menu_activated.disconnect(t._on_menu_activated) + watch_signals(t) + t._activate(_index_of(t, &"new_game")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"new_game"]) + t._activate(_index_of(t, &"quit")) + assert_signal_emitted_with_parameters(t, "menu_activated", [&"quit"]) + + +func test_footer_shows_version(): + var t := _title() + assert_string_contains(t._footer_left.text, Version.new().string()) diff --git a/client/tests/unit/test_title_screen.gd.uid b/client/tests/unit/test_title_screen.gd.uid new file mode 100644 index 0000000..c4f9b0c --- /dev/null +++ b/client/tests/unit/test_title_screen.gd.uid @@ -0,0 +1 @@ +uid://driqdhodq83g2