Add selectable startup world layouts

This commit is contained in:
Alexander Sellite 2026-08-20 21:43:04 -04:00
parent 5d55afcc26
commit 3d34ed0bee
24 changed files with 1139 additions and 78 deletions

View file

@ -627,6 +627,10 @@ func setup(
_refresh_all()
func set_world_interaction(interaction: ShopInteractionType) -> void:
_interaction = interaction
func open_shop() -> bool:
if (
visible

View file

@ -488,6 +488,16 @@ func setup(
)
func set_world_interactions(
shop_interaction: ShopInteractionType,
storage_interaction: PlayerStorageInteractionType,
) -> void:
_shop_interaction = shop_interaction
_storage_interaction = storage_interaction
_fishing_shop.set_world_interaction(shop_interaction)
_player_storage.set_world_interaction(storage_interaction)
func _prioritize_surface_drawing_pointer_input() -> void:
# Control input follows sibling order rather than CanvasItem.z_index. Chat is
# a full-screen Control with interactive mobile children, so the toolbar must

309
ui/new_game_setup_page.gd Normal file
View file

@ -0,0 +1,309 @@
class_name NewGameSetupPage
extends Control
signal start_requested(world_layout: StringName, world_seed: int)
signal back_requested
const WorldLayoutType = preload("res://world/world_layout.gd")
const SaveManagerType = preload("res://save/player_save_manager.gd")
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
enum SeedMode {
RANDOM,
CUSTOM,
}
@onready var _paper: PanelContainer = %Paper
@onready var _generated_button: Button = %GeneratedButton
@onready var _starter_button: Button = %StarterButton
@onready var _world_description: Label = %WorldDescription
@onready var _seed_section: VBoxContainer = %SeedSection
@onready var _random_seed_button: Button = %RandomSeedButton
@onready var _custom_seed_button: Button = %CustomSeedButton
@onready var _seed_edit: LineEdit = %SeedEdit
@onready var _seed_help: Label = %SeedHelp
@onready var _overwrite_warning: Label = %OverwriteWarning
@onready var _status: Label = %Status
@onready var _start_button: Button = %StartButton
@onready var _back_button: Button = %BackButton
var _world_layout: StringName = WorldLayoutType.GENERATED
var _seed_mode: SeedMode = SeedMode.RANDOM
var _random_seed: int = SaveManagerType.DEFAULT_WORLD_SEED
func _ready() -> void:
UtilityPageStyle.apply_page(self)
_paper.add_theme_stylebox_override(
"panel", UtilityPageStyle.panel_style()
)
for button: Button in [
_generated_button,
_starter_button,
_random_seed_button,
_custom_seed_button,
_start_button,
_back_button,
]:
UtilityPageStyle.apply_ocean_button(button)
UtilityPageStyle.apply_ocean_line_edit(_seed_edit)
_generated_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.GENERATED)
)
_starter_button.pressed.connect(
_set_world_layout.bind(WorldLayoutType.STARTER_ISLAND)
)
_random_seed_button.pressed.connect(_choose_random_seed)
_custom_seed_button.pressed.connect(_choose_custom_seed)
_seed_edit.text_changed.connect(_on_seed_text_changed)
_seed_edit.text_submitted.connect(_on_seed_submitted)
_start_button.pressed.connect(_request_start)
_back_button.pressed.connect(back_requested.emit)
hide()
func open_page(has_existing_progression: bool) -> void:
_world_layout = WorldLayoutType.GENERATED
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_overwrite_warning.visible = has_existing_progression
_status.text = ""
_refresh_presentation()
show()
UtilityPageStyle.animate_in(self)
_generated_button.grab_focus.call_deferred()
func close_page() -> void:
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and is_ancestor_of(focus_owner):
focus_owner.release_focus()
hide()
func request_back() -> void:
back_requested.emit()
func get_selected_world_layout() -> StringName:
return _world_layout
func get_selected_world_seed() -> int:
return (
_random_seed
if _seed_mode == SeedMode.RANDOM
else parse_seed_text(_seed_edit.text)
)
static func parse_seed_text(text: String) -> int:
var normalized: String = text.strip_edges()
if normalized.is_empty():
return 0
if normalized.is_valid_int():
var numeric_seed: int = int(normalized)
if (
numeric_seed <= 0
or numeric_seed > SaveManagerType.MAX_WORLD_SEED
):
return 0
return numeric_seed
var digest: PackedByteArray = normalized.sha256_buffer()
var hashed_seed: int = (
(int(digest[0]) << 24)
| (int(digest[1]) << 16)
| (int(digest[2]) << 8)
| int(digest[3])
) & 0x7fffffff
return (hashed_seed % SaveManagerType.MAX_WORLD_SEED) + 1
func _set_world_layout(layout: StringName) -> void:
if not WorldLayoutType.is_valid(layout):
return
_world_layout = layout
_status.text = ""
_refresh_presentation()
if layout == WorldLayoutType.GENERATED:
_random_seed_button.grab_focus.call_deferred()
else:
_start_button.grab_focus.call_deferred()
func _choose_random_seed() -> void:
_seed_mode = SeedMode.RANDOM
_roll_random_seed()
_status.text = ""
_refresh_presentation()
func _choose_custom_seed() -> void:
_seed_mode = SeedMode.CUSTOM
if parse_seed_text(_seed_edit.text) == _random_seed:
_seed_edit.text = ""
_status.text = ""
_refresh_presentation()
_seed_edit.grab_focus.call_deferred()
_seed_edit.select_all.call_deferred()
func _roll_random_seed() -> void:
_random_seed = SaveManagerType.roll_world_seed()
_seed_edit.text = str(_random_seed)
func _on_seed_text_changed(_text: String) -> void:
if _seed_mode != SeedMode.CUSTOM:
return
_status.text = ""
_refresh_start_state()
func _on_seed_submitted(_text: String) -> void:
_request_start()
func _request_start() -> void:
var seed: int = get_selected_world_seed()
if _world_layout == WorldLayoutType.GENERATED and seed == 0:
_status.text = (
"enter some text or a whole number from 1 to %d."
% SaveManagerType.MAX_WORLD_SEED
)
_seed_edit.grab_focus()
_seed_edit.select_all()
return
if seed == 0:
seed = SaveManagerType.roll_world_seed()
_start_button.disabled = true
start_requested.emit(_world_layout, seed)
func _refresh_presentation() -> void:
var generated: bool = _world_layout == WorldLayoutType.GENERATED
_generated_button.set_pressed_no_signal(generated)
_starter_button.set_pressed_no_signal(not generated)
_world_description.text = (
"build a new island from terrain chunks. the same seed always "
+ "builds the same world."
if generated
else "play on the authored starter island."
)
_seed_section.visible = generated
_random_seed_button.set_pressed_no_signal(
_seed_mode == SeedMode.RANDOM
)
_custom_seed_button.set_pressed_no_signal(
_seed_mode == SeedMode.CUSTOM
)
_seed_edit.editable = _seed_mode == SeedMode.CUSTOM
_seed_edit.focus_mode = (
Control.FOCUS_ALL
if _seed_mode == SeedMode.CUSTOM
else Control.FOCUS_NONE
)
_seed_help.text = (
"press random seed again to roll another world."
if _seed_mode == SeedMode.RANDOM
else "enter text or a number from 1 to %d. the same entry makes the same world."
% SaveManagerType.MAX_WORLD_SEED
)
_refresh_start_state()
_configure_controller_focus()
func _refresh_start_state() -> void:
_start_button.disabled = (
_world_layout == WorldLayoutType.GENERATED
and _seed_mode == SeedMode.CUSTOM
and parse_seed_text(_seed_edit.text) == 0
)
func _configure_controller_focus() -> void:
var generated: bool = _world_layout == WorldLayoutType.GENERATED
var custom: bool = generated and _seed_mode == SeedMode.CUSTOM
_set_neighbors(
_generated_button,
_generated_button,
_starter_button,
_generated_button,
_random_seed_button if generated else _start_button,
)
_set_neighbors(
_starter_button,
_generated_button,
_starter_button,
_starter_button,
_custom_seed_button if generated else _back_button,
)
_set_neighbors(
_random_seed_button,
_random_seed_button,
_custom_seed_button,
_generated_button,
_seed_edit if custom else _start_button,
)
_set_neighbors(
_custom_seed_button,
_random_seed_button,
_custom_seed_button,
_starter_button,
_seed_edit if custom else _back_button,
)
_set_neighbors(
_seed_edit,
_seed_edit,
_seed_edit,
_custom_seed_button,
_start_button,
)
var action_top: Control = (
_seed_edit
if custom
else _random_seed_button
if generated
else _generated_button
)
_set_neighbors(
_start_button,
_start_button,
_back_button,
action_top,
_start_button,
)
_set_neighbors(
_back_button,
_start_button,
_back_button,
action_top,
_back_button,
)
var traversal: Array[Control] = [
_generated_button,
_starter_button,
]
if generated:
traversal.append(_random_seed_button)
traversal.append(_custom_seed_button)
if custom:
traversal.append(_seed_edit)
traversal.append(_start_button)
traversal.append(_back_button)
ControllerFocusNavigationType.configure_traversal(traversal)
func _set_neighbors(
control: Control,
left: Control,
right: Control,
top: Control,
bottom: Control,
) -> void:
control.focus_neighbor_left = control.get_path_to(left)
control.focus_neighbor_right = control.get_path_to(right)
control.focus_neighbor_top = control.get_path_to(top)
control.focus_neighbor_bottom = control.get_path_to(bottom)

View file

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

170
ui/new_game_setup_page.tscn Normal file
View file

@ -0,0 +1,170 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/new_game_setup_page.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[node name="NewGameSetupPage" type="Control"]
visible = false
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme = ExtResource("2_theme")
script = ExtResource("1_script")
[node name="Paper" type="PanelContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -360.0
offset_top = -280.0
offset_right = 360.0
offset_bottom = 280.0
grow_horizontal = 2
grow_vertical = 2
[node name="Margin" type="MarginContainer" parent="Paper"]
layout_mode = 2
theme_override_constants/margin_left = 46
theme_override_constants/margin_top = 30
theme_override_constants/margin_right = 46
theme_override_constants/margin_bottom = 30
[node name="Layout" type="VBoxContainer" parent="Paper/Margin"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="Title" type="Label" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 32
text = "new game"
horizontal_alignment = 1
[node name="Prompt" type="Label" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 17
text = "choose where you want to begin"
horizontal_alignment = 1
[node name="WorldButtons" type="HBoxContainer" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="GeneratedButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 58)
layout_mode = 2
toggle_mode = true
text = "generate a world"
[node name="StarterButton" type="Button" parent="Paper/Margin/Layout/WorldButtons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(230, 58)
layout_mode = 2
toggle_mode = true
text = "starter island"
[node name="WorldDescription" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 16
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2
[node name="SeedSection" type="VBoxContainer" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="SeedLabel" type="Label" parent="Paper/Margin/Layout/SeedSection"]
layout_mode = 2
theme_override_colors/font_color = Color(0.902, 0.969, 0.969, 1)
theme_override_font_sizes/font_size = 20
text = "world seed"
horizontal_alignment = 1
[node name="SeedModes" type="HBoxContainer" parent="Paper/Margin/Layout/SeedSection"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="RandomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 48)
layout_mode = 2
toggle_mode = true
text = "random seed"
[node name="CustomSeedButton" type="Button" parent="Paper/Margin/Layout/SeedSection/SeedModes"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 48)
layout_mode = 2
toggle_mode = true
text = "enter a seed"
[node name="SeedEdit" type="LineEdit" parent="Paper/Margin/Layout/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 46)
layout_mode = 2
theme_override_font_sizes/font_size = 20
placeholder_text = "number or words"
alignment = 1
max_length = 64
[node name="SeedHelp" type="Label" parent="Paper/Margin/Layout/SeedSection"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_colors/font_color = Color(0.624, 0.812, 0.824, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
[node name="OverwriteWarning" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.78, 0.62, 1)
theme_override_font_sizes/font_size = 15
text = "starting a new game replaces your existing progression."
horizontal_alignment = 1
[node name="Status" type="Label" parent="Paper/Margin/Layout"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 28)
layout_mode = 2
theme_override_colors/font_color = Color(1, 0.7, 0.65, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1
[node name="Spacer" type="Control" parent="Paper/Margin/Layout"]
custom_minimum_size = Vector2(0, 4)
layout_mode = 2
size_flags_vertical = 3
[node name="Actions" type="HBoxContainer" parent="Paper/Margin/Layout"]
layout_mode = 2
theme_override_constants/separation = 14
alignment = 1
[node name="StartButton" type="Button" parent="Paper/Margin/Layout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(190, 54)
layout_mode = 2
text = "start new game"
[node name="BackButton" type="Button" parent="Paper/Margin/Layout/Actions"]
unique_name_in_owner = true
custom_minimum_size = Vector2(150, 54)
layout_mode = 2
text = "back"

View file

@ -70,6 +70,10 @@ func setup(
_refresh()
func set_world_interaction(interaction: PlayerStorageInteraction) -> void:
_interaction = interaction
func open_storage() -> bool:
if (
visible

View file

@ -758,11 +758,15 @@ func _progression_import_file_selected(path: String) -> void:
dialog.ok_button_text = "import progression"
dialog.dialog_text = (
"this will replace the current progression after making a backup.\n\n"
+ "fish: %d\ndiscovered: %d\nworld seed: %d\n\n"
+ "fish: %d\ndiscovered: %d\nworld: %s\nworld seed: %d\n\n"
+ "identities, settings, friends, bans, and trusted servers are unchanged."
) % [
int(inspected.get("catch_count", 0)),
int(inspected.get("discovered_species_count", 0)),
WorldLayout.display_name(inspected.get(
"world_layout",
String(WorldLayout.GENERATED),
)),
int(inspected.get("world_seed", 0)),
]
dialog.confirmed.connect(_confirm_progression_import.bind(dialog))

View file

@ -26,6 +26,7 @@ const TitleCreditsPageType = preload("res://ui/title_credits_page.gd")
const NetworkSessionType = preload("res://network/network_session.gd")
const SavedServerStoreType = preload("res://network/saved_server_store.gd")
const JoinGamePageType = preload("res://ui/network/join_game_page.gd")
const NewGameSetupPageType = preload("res://ui/new_game_setup_page.gd")
const CurrencyPresentationType = preload(
"res://ui/currency_presentation.gd"
)
@ -94,14 +95,13 @@ const INTRO_BRANDING_TRAVEL_DURATION: float = 2.0
const INTRO_BRANDING_START_DELAY: float = 0.35
const HOST_CLUSTER_SAFE_MARGIN: float = 24.0
signal new_game_requested(world_seed: int)
signal new_game_requested(world_layout: StringName, world_seed: int)
signal continue_game_requested
signal quit_requested
signal join_game_requested(endpoint: String)
enum ConfirmationAction {
NONE,
NEW_GAME,
DELETE_SAVE,
}
@ -142,12 +142,12 @@ enum ConfirmationAction {
@onready var _start_prompt_center: CenterContainer = %StartPromptCenter
@onready var _start_prompt_label: Label = %StartPromptLabel
@onready var _join_game_page: JoinGamePageType = %JoinGamePage
@onready var _new_game_setup_page: NewGameSetupPageType = %NewGameSetupPage
@onready var _credits_page: TitleCreditsPageType = %CreditsPage
var _save_manager: SaveManagerType
var _settings_manager: SettingsManagerType
var _inspection: SaveInspectionType
var _pending_new_game_seed: int = PlayerSaveManager.DEFAULT_WORLD_SEED
var _confirmation_action: ConfirmationAction = ConfirmationAction.NONE
var _action_in_progress: bool = false
var _decorative_rng := RandomNumberGenerator.new()
@ -210,6 +210,8 @@ func _ready() -> void:
)
_new_game_button.pressed.connect(_on_new_game_pressed)
_join_game_button.pressed.connect(_open_join_game)
_new_game_setup_page.start_requested.connect(_start_configured_new_game)
_new_game_setup_page.back_requested.connect(_close_new_game_setup)
_settings_button.pressed.connect(_open_settings)
_credits_button.pressed.connect(_open_credits)
_delete_button.pressed.connect(_on_delete_pressed)
@ -283,6 +285,7 @@ func reopen() -> void:
_reset_confirmation()
_settings_panel.hide()
_join_game_page.close_page()
_new_game_setup_page.close_page()
_credits_page.close_page()
_refresh_save_inspection()
show()
@ -311,6 +314,7 @@ func open_join_game_page(endpoint: String = "") -> void:
_settings_panel.hide()
_confirmation_page.hide_page()
_credits_page.close_page()
_new_game_setup_page.close_page()
_join_game_page.open_page(endpoint)
@ -326,6 +330,7 @@ func _open_join_game() -> void:
if (
_action_in_progress
or _is_confirmation_active()
or _new_game_setup_page.visible
or _credits_page.visible
):
return
@ -346,6 +351,7 @@ func _open_credits() -> void:
or _is_confirmation_active()
or _settings_panel.visible
or _join_game_page.visible
or _new_game_setup_page.visible
or _credits_page.visible
):
return
@ -583,6 +589,11 @@ func _input(event: InputEvent) -> void:
_join_game_page.request_back()
get_viewport().set_input_as_handled()
return
if _new_game_setup_page.visible:
if event.is_action_pressed("ui_cancel"):
_new_game_setup_page.request_back()
get_viewport().set_input_as_handled()
return
if (
_title_settings_transition_active
or _title_entry_transition_active
@ -629,6 +640,7 @@ func _handle_primary_menu_focus_input(event: InputEvent) -> bool:
not _button_center.visible
or _is_confirmation_active()
or _settings_panel.visible
or _new_game_setup_page.visible
or _credits_page.visible
):
return false
@ -1031,19 +1043,59 @@ func _on_new_game_pressed() -> void:
"the existing save cannot be accessed safely."
)
return
_pending_new_game_seed = PlayerSaveManager.roll_world_seed()
var warning := "start a new world?"
if _inspection.status != SaveInspectionType.Status.MISSING:
warning += " existing progression will be deleted."
warning += "\nworld seed: %d" % _pending_new_game_seed
_open_confirmation(
ConfirmationAction.NEW_GAME,
warning,
"start\nnew game",
true
_open_new_game_setup(
_inspection.status != SaveInspectionType.Status.MISSING
)
func _open_new_game_setup(has_existing_progression: bool) -> void:
_modal_restore_navigation_focus = _navigation_focus_active
_set_title_bubbles_interactive(false)
_release_primary_menu_focus()
_presentation_center.hide()
_start_prompt_center.hide()
_join_game_page.close_page()
_credits_page.close_page()
_new_game_setup_page.open_page(has_existing_progression)
func _close_new_game_setup() -> void:
_new_game_setup_page.close_page()
_presentation_center.show()
_button_center.show()
_start_prompt_center.hide()
_set_title_bubbles_interactive(true)
var restore_navigation_focus: bool = _modal_restore_navigation_focus
_modal_restore_navigation_focus = false
if restore_navigation_focus:
_navigation_focus_active = true
_new_game_button.grab_focus()
else:
_navigation_focus_active = false
_release_title_focus()
_update_continue_stats_visibility()
func _start_configured_new_game(
world_layout: StringName,
world_seed: int,
) -> void:
if _action_in_progress or not _new_game_setup_page.visible:
return
_new_game_setup_page.close_page()
_action_in_progress = true
new_game_requested.emit(world_layout, world_seed)
_action_in_progress = false
# Signal delivery is synchronous. A successful start hides this title screen;
# if it remains visible, restore the menu so a generation error is recoverable.
if visible:
_presentation_center.show()
_button_center.show()
_set_title_bubbles_interactive(true)
_navigation_focus_active = true
_new_game_button.grab_focus()
func _on_delete_pressed() -> void:
if (
_action_in_progress
@ -1071,14 +1123,6 @@ func _on_confirmation_accepted() -> void:
var action: ConfirmationAction = _confirmation_action
_confirmation_page.lock_interaction()
_action_in_progress = true
if action == ConfirmationAction.NEW_GAME:
_confirmation_action = ConfirmationAction.NONE
_confirmation_transition_generation += 1
_cancel_confirmation_transition()
_confirmation_page.hide_page()
new_game_requested.emit(_pending_new_game_seed)
_action_in_progress = false
return
if not _save_manager.delete_progression_save():
_feedback_label.text = _center_feedback_text(
"failed to delete saved progression."

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=23 format=3]
[gd_scene load_steps=24 format=3]
[ext_resource type="Script" path="res://ui/title_screen.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
@ -19,6 +19,7 @@
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/new_game.png" id="17_new_game"]
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/credits.png" id="18_credits"]
[ext_resource type="Texture2D" path="res://ui/icons/main_menu/delete_save.png" id="19_delete_save"]
[ext_resource type="PackedScene" path="res://ui/new_game_setup_page.tscn" id="20_new_game_setup"]
[sub_resource type="ShaderMaterial" id="ShaderMaterial_title_water"]
shader = ExtResource("4_water_shader")
@ -486,3 +487,13 @@ anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="NewGameSetupPage" parent="ResponsiveTitleStage/TitlePresentationScaleRoot" instance=ExtResource("20_new_game_setup")]
unique_name_in_owner = true
z_index = 220
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2