feat(title): TitleScreen composition, menu + selection, boot scene
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ config_version=5
|
|||||||
config/name="coc-rpg"
|
config/name="coc-rpg"
|
||||||
config/version="0.01-alpha"
|
config/version="0.01-alpha"
|
||||||
config/description="AI-driven single-player party RPG. Code owns state. AI owns text."
|
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")
|
config/features=PackedStringArray("4.7")
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|||||||
228
client/scenes/title/TitleScreen.tscn
Normal file
228
client/scenes/title/TitleScreen.tscn
Normal file
@@ -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
|
||||||
95
client/scripts/ui/title/title_screen.gd
Normal file
95
client/scripts/ui/title/title_screen.gd
Normal file
@@ -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)
|
||||||
1
client/scripts/ui/title/title_screen.gd.uid
Normal file
1
client/scripts/ui/title/title_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://clmbt3dy7bdwb
|
||||||
52
client/tests/unit/test_title_screen.gd
Normal file
52
client/tests/unit/test_title_screen.gd
Normal file
@@ -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())
|
||||||
1
client/tests/unit/test_title_screen.gd.uid
Normal file
1
client/tests/unit/test_title_screen.gd.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://driqdhodq83g2
|
||||||
Reference in New Issue
Block a user